PluginProbe
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages / 3.4.3
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages v3.4.3
3.4.3 3.4.2 3.4.1 3.4.0 3.3.9 3.3.8 3.3.7 3.3.6 3.3.5 3.3.4 3.3.3 3.3.2 3.3.1 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.2.6 2.2.7 2.2.8 2.2.9 2.3.0 2.3.1 All 196 releases
convertkit / vendor / wordpress / mcp-adapter / includes / Transport / Infrastructure / RequestRouter.php

RequestRouter.php in Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages 3.4.3, at vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/RequestRouter.php

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