PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.8
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.8
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / lib / mcp-adapter / includes / Transport / Infrastructure / RequestRouter.php

RequestRouter.php in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.8, at lib/mcp-adapter/includes/Transport/Infrastructure/RequestRouter.php

383 lines 13.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Service for routing MCP requests to appropriate handlers.
4 *
5 * @package McpAdapter
6 */
7
8 declare( strict_types=1 );
9
10 namespace WP\MCP\Transport\Infrastructure;
11
12 use WP\MCP\Infrastructure\ErrorHandling\McpErrorFactory;
13 use WP\MCP\Infrastructure\Observability\McpObservabilityHelperTrait;
14 use WP\McpSchema\Common\AbstractDataTransferObject;
15 use WP\McpSchema\Common\Content\DTO\TextContent;
16 use WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse;
17 use WP\McpSchema\Server\Tools\DTO\CallToolResult;
18
19 /**
20 * Service for routing MCP requests to appropriate handlers.
21 *
22 * Extracted from AbstractMcpTransport to be reusable across
23 * all transport implementations via dependency injection.
24 */
25 class RequestRouter {
26
27 /**
28 * The transport context.
29 *
30 * @var \WP\MCP\Transport\Infrastructure\McpTransportContext
31 */
32 private McpTransportContext $context;
33
34 /**
35 * Initialize the request router.
36 *
37 * @param \WP\MCP\Transport\Infrastructure\McpTransportContext $context The transport context.
38 */
39 public function __construct(
40 McpTransportContext $context
41 ) {
42 $this->context = $context;
43 }
44
45 /**
46 * Route a request to the appropriate handler.
47 *
48 * @param string $method The MCP method name.
49 * @param array $params The request parameters.
50 * @param mixed $request_id The request ID (for JSON-RPC) - string, number, or null.
51 * @param string $transport_name Transport name for observability.
52 * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext|null $http_context HTTP context for session management.
53 *
54 * @return array
55 */
56 public function route_request( string $method, array $params, $request_id = 0, string $transport_name = 'unknown', ?HttpRequestContext $http_context = null ): array {
57 // Track request start time.
58 $start_time = microtime( true );
59
60 $new_session_id = null;
61 $component_tags = $this->resolve_component_observability_context( $method, $params );
62
63 // Common tags for all metrics.
64 $common_tags = array(
65 'method' => $method,
66 'transport' => $transport_name,
67 'server_id' => $this->context->mcp_server->get_server_id(),
68 'params' => $this->sanitize_params_for_logging( $params ),
69 'request_id' => $request_id,
70 'session_id' => $http_context ? $http_context->session_id : null,
71 );
72
73 $handlers = array(
74 'initialize' => function () use ( $params, $request_id, $http_context, &$new_session_id ) {
75 return $this->handle_initialize_with_session( $params, $request_id, $http_context, $new_session_id );
76 },
77 'ping' => fn() => $this->context->system_handler->ping(),
78 'tools/list' => fn() => $this->context->tools_handler->list_tools(),
79 'tools/list/all' => fn() => $this->context->tools_handler->list_all_tools(),
80 'tools/call' => fn() => $this->context->tools_handler->call_tool( $params, $request_id ),
81 'resources/list' => fn() => $this->context->resources_handler->list_resources(),
82 'resources/read' => fn() => $this->context->resources_handler->read_resource( $params, $request_id ),
83 'prompts/list' => fn() => $this->context->prompts_handler->list_prompts(),
84 'prompts/get' => fn() => $this->context->prompts_handler->get_prompt( $params, $request_id ),
85 );
86
87 try {
88 $handler_result = isset( $handlers[ $method ] ) ? $handlers[ $method ]() : $this->create_method_not_found_error( $method, $request_id );
89
90 // Calculate request duration.
91 $duration = ( microtime( true ) - $start_time ) * 1000; // Convert to milliseconds.
92
93 // Handle DTO results from migrated handlers.
94 // DTOs are converted to arrays at the serialization boundary (here).
95 if ( $handler_result instanceof JSONRPCErrorResponse ) {
96 // Normalize to transport-level shape: only the JSON-RPC error object.
97 // The JSON-RPC envelope is created by the transport boundary.
98 $result = array( 'error' => $handler_result->getError()->toArray() );
99 $tags = array_merge( $common_tags, $component_tags, array( 'status' => 'error' ) );
100 $tags['error_code'] = $handler_result->getError()->getCode();
101 $tags['failure_reason'] = $handler_result->getError()->getMessage();
102 $this->context->observability_handler->record_event( 'mcp.request', $tags, $duration );
103
104 return $result;
105 }
106
107 if ( $handler_result instanceof AbstractDataTransferObject ) {
108 // Success DTO (ListToolsResult, CallToolResult, etc.) - convert to array.
109 // Note: If a future schema version ever returns nested DTO objects inside `toArray()`,
110 // we may need to add a deep normalizer at this boundary (before JSON serialization)
111 // to prevent placeholder `{}` objects in client output.
112 $raw_result = $handler_result->toArray();
113 $result = $raw_result;
114
115 if ( null !== $new_session_id ) {
116 $component_tags['new_session_id'] = $new_session_id;
117 $result['_session_id'] = $new_session_id;
118 }
119
120 $status = 'success';
121 if ( $handler_result instanceof CallToolResult && true === $handler_result->getIsError() ) {
122 $status = 'error';
123
124 if ( ! isset( $component_tags['failure_reason'] ) ) {
125 $content = $handler_result->getContent();
126 if ( isset( $content[0] ) && $content[0] instanceof TextContent ) {
127 $component_tags['failure_reason'] = $content[0]->getText();
128 }
129 }
130 }
131
132 $tags = array_merge( $common_tags, $component_tags, array( 'status' => $status ) );
133 $this->context->observability_handler->record_event( 'mcp.request', $tags, $duration );
134
135 return $result;
136 }
137
138 // Handlers should only return schema DTOs.
139 $actual_type = is_object( $handler_result ) ? get_class( $handler_result ) : gettype( $handler_result );
140 $this->context->error_handler->log(
141 sprintf( 'Handler for method "%s" returned unexpected type: %s', $method, $actual_type ),
142 array(
143 'method' => $method,
144 'actual_type' => $actual_type,
145 )
146 );
147 $unexpected_error = McpErrorFactory::internal_error( $request_id, 'Handler returned invalid response type.' );
148 $result = array( 'error' => $unexpected_error->getError()->toArray() );
149 $tags = array_merge( $common_tags, $component_tags, array( 'status' => 'error' ) );
150 $tags['error_code'] = $unexpected_error->getError()->getCode();
151 $this->context->observability_handler->record_event( 'mcp.request', $tags, $duration );
152
153 return $result;
154 } catch ( \Throwable $exception ) {
155 // Calculate request duration.
156 $duration = ( microtime( true ) - $start_time ) * 1000; // Convert to milliseconds.
157
158 // Track exception with categorization.
159 $tags = array_merge(
160 $common_tags,
161 $component_tags,
162 array(
163 'status' => 'error',
164 'error_type' => get_class( $exception ),
165 'error_category' => $this->categorize_error( $exception ),
166 )
167 );
168 $this->context->observability_handler->record_event( 'mcp.request', $tags, $duration );
169
170 // Create error response from exception.
171 $unexpected_error = McpErrorFactory::internal_error( $request_id, 'Handler error occurred' );
172
173 return array( 'error' => $unexpected_error->getError()->toArray() );
174 }
175 }
176
177 /**
178 * Resolve per-component observability tags for a request.
179 *
180 * This replaces legacy approaches that derived tags from DTO `_meta`.
181 *
182 * @param string $method MCP method name.
183 * @param array $params Request parameters (root or nested under `params`).
184 *
185 * @return array<string, mixed>
186 */
187 private function resolve_component_observability_context( string $method, array $params ): array {
188 $request_params = $params['params'] ?? $params;
189
190 if ( ! is_array( $request_params ) ) {
191 $request_params = array();
192 }
193
194 switch ( $method ) {
195 case 'tools/call':
196 $tool_name = $request_params['name'] ?? null;
197 $tool_name = is_string( $tool_name ) ? trim( $tool_name ) : null;
198
199 if ( null === $tool_name || '' === $tool_name ) {
200 return array();
201 }
202
203 $mcp_tool = $this->context->mcp_server->get_mcp_tool( $tool_name );
204 if ( $mcp_tool ) {
205 return $mcp_tool->get_observability_context();
206 }
207
208 return array(
209 'component_type' => 'tool',
210 'tool_name' => $tool_name,
211 );
212
213 case 'prompts/get':
214 $prompt_name = $request_params['name'] ?? null;
215 $prompt_name = is_string( $prompt_name ) ? trim( $prompt_name ) : null;
216
217 if ( null === $prompt_name || '' === $prompt_name ) {
218 return array();
219 }
220
221 $mcp_prompt = $this->context->mcp_server->get_mcp_prompt( $prompt_name );
222 if ( $mcp_prompt ) {
223 return $mcp_prompt->get_observability_context();
224 }
225
226 return array(
227 'component_type' => 'prompt',
228 'prompt_name' => $prompt_name,
229 );
230
231 case 'resources/read':
232 $resource_uri = $request_params['uri'] ?? null;
233 $resource_uri = is_string( $resource_uri ) ? trim( $resource_uri ) : null;
234
235 if ( null === $resource_uri || '' === $resource_uri ) {
236 return array();
237 }
238
239 $mcp_resource = $this->context->mcp_server->get_mcp_resource( $resource_uri );
240 if ( $mcp_resource ) {
241 return $mcp_resource->get_observability_context();
242 }
243
244 return array(
245 'component_type' => 'resource',
246 'resource_uri' => $resource_uri,
247 );
248 }
249
250 return array();
251 }
252
253 /**
254 * Sanitize request params for logging to remove sensitive data and limit size.
255 *
256 * @param array $params The request parameters to sanitize.
257 *
258 * @return array Sanitized parameters safe for logging.
259 */
260 private function sanitize_params_for_logging( array $params ): array {
261 // Return early for empty parameters.
262 if ( empty( $params ) ) {
263 return array();
264 }
265
266 $sanitized = array();
267
268 // Extract only safe, useful fields for observability
269 $safe_fields = array( 'name', 'protocolVersion', 'uri' );
270
271 foreach ( $safe_fields as $field ) {
272 if ( ! isset( $params[ $field ] ) || ! is_scalar( $params[ $field ] ) ) {
273 continue;
274 }
275
276 $sanitized[ $field ] = $params[ $field ];
277 }
278
279 // Add clientInfo name if available (useful for debugging)
280 if ( isset( $params['clientInfo']['name'] ) ) {
281 $sanitized['client_name'] = $params['clientInfo']['name'];
282 }
283
284 // Add arguments count for tool calls (but not the actual arguments to avoid logging sensitive data).
285 // Also filter out sensitive-looking keys to avoid leaking secret names.
286 if ( isset( $params['arguments'] ) && is_array( $params['arguments'] ) ) {
287 $sanitized['arguments_count'] = count( $params['arguments'] );
288
289 // Filter argument keys to exclude sensitive-looking ones.
290 $safe_keys = array();
291 foreach ( array_keys( $params['arguments'] ) as $arg_key ) {
292 if ( McpObservabilityHelperTrait::is_sensitive_key( (string) $arg_key ) ) {
293 $safe_keys[] = '[REDACTED]';
294 } else {
295 $safe_keys[] = $arg_key;
296 }
297 }
298 $sanitized['arguments_keys'] = $safe_keys;
299 }
300
301 return $sanitized;
302 }
303
304 /**
305 * Handle initialize requests with session management.
306 *
307 * Converts InitializeResult DTO to array and adds session management.
308 *
309 * @param array $params The request parameters.
310 * @param mixed $request_id The request ID.
311 * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext|null $http_context HTTP context for session management.
312 * @param string|null $new_session_id Newly created session id, if any.
313 *
314 * @return \WP\McpSchema\Common\AbstractDataTransferObject
315 */
316 private function handle_initialize_with_session( array $params, $request_id, ?HttpRequestContext $http_context, ?string &$new_session_id = null ): AbstractDataTransferObject {
317 // Extract client protocol version from params, defaulting to empty string if missing.
318 $client_version = isset( $params['protocolVersion'] ) && is_string( $params['protocolVersion'] ) ? $params['protocolVersion'] : '';
319
320 // Get the initialize response from the handler (returns InitializeResult DTO).
321 $init_result = $this->context->initialize_handler->handle( $client_version );
322
323 // Handle session creation if HTTP context is provided.
324 // InitializeResult DTO never has errors - errors would be thrown as exceptions.
325 if ( $http_context && ! $http_context->session_id ) {
326 $session_result = HttpSessionValidator::create_session( $params );
327
328 if ( is_array( $session_result ) ) {
329 $error = $session_result['error'] ?? array();
330
331 return McpErrorFactory::create_error_response(
332 $request_id,
333 isset( $error['code'] ) ? (int) $error['code'] : McpErrorFactory::INTERNAL_ERROR,
334 (string) ( $error['message'] ?? __( 'Failed to create session', 'mcp-adapter' ) ),
335 $error['data'] ?? null
336 );
337 }
338
339 $new_session_id = $session_result;
340 }
341
342 return $init_result;
343 }
344
345 /**
346 * Create a method not found error with generic format.
347 *
348 * @param string $method The method that was not found.
349 * @param mixed $request_id The request ID.
350 *
351 * @return \WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse
352 */
353 private function create_method_not_found_error( string $method, $request_id ): JSONRPCErrorResponse {
354 return McpErrorFactory::method_not_found( $request_id, $method );
355 }
356
357 /**
358 * Categorize an exception into a general error category.
359 *
360 * @param \Throwable $exception The exception to categorize.
361 *
362 * @return string
363 */
364 private function categorize_error( \Throwable $exception ): string {
365 $error_categories = array(
366 \ArgumentCountError::class => 'arguments',
367 \TypeError::class => 'type',
368 \InvalidArgumentException::class => 'validation',
369 \LogicException::class => 'logic',
370 \RuntimeException::class => 'execution',
371 \Error::class => 'system',
372 );
373
374 foreach ( $error_categories as $class => $category ) {
375 if ( $exception instanceof $class ) {
376 return $category;
377 }
378 }
379
380 return 'unknown';
381 }
382 }
383