PluginProbe
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF / trunk
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF vtrunk
2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.9 2.2.8 trunk 1.10 1.3.3 1.3.4 1.3.5 1.3.5.1 1.3.5.2 1.3.6 1.3.6.1 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.5 All 103 releases
imagify / vendor / wordpress / mcp-adapter / includes / Transport / Infrastructure / RequestRouter.php

RequestRouter.php in Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF trunk, at vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/RequestRouter.php

384 lines 13.5 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\ErrorLogMcpObservabilityHandler;
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 // @todo Replace this with a less-coupled way to access `McpObservabilityHelperTrait:is_sensitive_key()`.
293 if ( ErrorLogMcpObservabilityHandler::is_sensitive_key( (string) $arg_key ) ) {
294 $safe_keys[] = '[REDACTED]';
295 } else {
296 $safe_keys[] = $arg_key;
297 }
298 }
299 $sanitized['arguments_keys'] = $safe_keys;
300 }
301
302 return $sanitized;
303 }
304
305 /**
306 * Handle initialize requests with session management.
307 *
308 * Converts InitializeResult DTO to array and adds session management.
309 *
310 * @param array $params The request parameters.
311 * @param mixed $request_id The request ID.
312 * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext|null $http_context HTTP context for session management.
313 * @param string|null $new_session_id Newly created session id, if any.
314 *
315 * @return \WP\McpSchema\Common\AbstractDataTransferObject
316 */
317 private function handle_initialize_with_session( array $params, $request_id, ?HttpRequestContext $http_context, ?string &$new_session_id = null ): AbstractDataTransferObject {
318 // Extract client protocol version from params, defaulting to empty string if missing.
319 $client_version = isset( $params['protocolVersion'] ) && is_string( $params['protocolVersion'] ) ? $params['protocolVersion'] : '';
320
321 // Get the initialize response from the handler (returns InitializeResult DTO).
322 $init_result = $this->context->initialize_handler->handle( $client_version );
323
324 // Handle session creation if HTTP context is provided.
325 // InitializeResult DTO never has errors - errors would be thrown as exceptions.
326 if ( $http_context && ! $http_context->session_id ) {
327 $session_result = HttpSessionValidator::create_session( $params );
328
329 if ( is_array( $session_result ) ) {
330 $error = $session_result['error'] ?? array();
331
332 return McpErrorFactory::create_error_response(
333 $request_id,
334 isset( $error['code'] ) ? (int) $error['code'] : McpErrorFactory::INTERNAL_ERROR,
335 (string) ( $error['message'] ?? __( 'Failed to create session', 'mcp-adapter' ) ),
336 $error['data'] ?? null
337 );
338 }
339
340 $new_session_id = $session_result;
341 }
342
343 return $init_result;
344 }
345
346 /**
347 * Create a method not found error with generic format.
348 *
349 * @param string $method The method that was not found.
350 * @param mixed $request_id The request ID.
351 *
352 * @return \WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse
353 */
354 private function create_method_not_found_error( string $method, $request_id ): JSONRPCErrorResponse {
355 return McpErrorFactory::method_not_found( $request_id, $method );
356 }
357
358 /**
359 * Categorize an exception into a general error category.
360 *
361 * @param \Throwable $exception The exception to categorize.
362 *
363 * @return string
364 */
365 private function categorize_error( \Throwable $exception ): string {
366 $error_categories = array(
367 \ArgumentCountError::class => 'arguments',
368 \TypeError::class => 'type',
369 \InvalidArgumentException::class => 'validation',
370 \LogicException::class => 'logic',
371 \RuntimeException::class => 'execution',
372 \Error::class => 'system',
373 );
374
375 foreach ( $error_categories as $class => $category ) {
376 if ( $exception instanceof $class ) {
377 return $category;
378 }
379 }
380
381 return 'unknown';
382 }
383 }
384