PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.13.3
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.13.3
3.13.3 3.13.2 3.13.1 3.13.0 3.12.13 3.12.12 3.12.11 3.12.10 3.12.9 3.12.8 3.12.7 3.12.6 3.12.5 3.12.4 3.12.3 3.12.1 3.12.2 3.12.0 3.11.1 3.11.0 3.10.9 3.10.8 3.10.7 3.10.6 2.8.16 All 261 releases
wpfunnels / vendor / wordpress / mcp-adapter / includes / Transport / Infrastructure / RequestRouter.php

RequestRouter.php in WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell 3.13.3, at vendor/wordpress/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