PluginProbe ʕ •ᴥ•ʔ
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). / trunk
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). vtrunk
3.0.3 3.0.2 3.0.1 trunk 2.2.14 2.2.15 2.2.16 2.2.17 2.2.18 2.2.19 2.3.0 2.3.1 2.3.10 2.3.11 2.3.12 2.3.13 2.3.14 2.3.2 2.3.3 2.3.4 2.3.5 2.3.6 2.3.7 2.3.8 2.3.9 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1 2.5.2 2.6.0 2.6.1 2.6.2 2.6.3 2.6.4 2.6.5 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 2.7.91 2.7.92 2.7.93 2.8.0 2.8.1 2.8.2 2.8.3 2.8.4 2.8.5 2.9.0 2.9.1 2.9.2 2.9.3 3.0.0
commercebird / vendor / wordpress / mcp-adapter / includes / Cli / StdioServerBridge.php
commercebird / vendor / wordpress / mcp-adapter / includes / Cli Last commit date
McpCommand.php 2 months ago StdioServerBridge.php 2 months ago
StdioServerBridge.php
350 lines
1 <?php
2 /**
3 * STDIO Server Bridge for MCP Adapter
4 *
5 * This service acts as a bridge between the STDIO protocol and MCP servers,
6 * allowing any registered server to be exposed via command-line interface.
7 *
8 * @package McpAdapter
9 */
10
11 declare( strict_types=1 );
12
13 namespace WP\MCP\Cli;
14
15 use WP\MCP\Core\McpServer;
16 use WP\MCP\Infrastructure\ErrorHandling\McpErrorFactory;
17 use WP\MCP\Transport\Infrastructure\JsonRpcResponseBuilder;
18 use WP\MCP\Transport\Infrastructure\RequestRouter;
19
20 /**
21 * STDIO Server Bridge - Exposes MCP servers via STDIO protocol
22 *
23 * This service handles JSON-RPC communication over stdin/stdout and delegates
24 * requests to the appropriate MCP server. Unlike transport implementations,
25 * this is a presentation layer service that can work with any server.
26 */
27 class StdioServerBridge {
28
29 /**
30 * The MCP server to expose via STDIO.
31 *
32 * @var \WP\MCP\Core\McpServer
33 */
34 private McpServer $server;
35
36 /**
37 * Request router for handling MCP requests.
38 *
39 * @var \WP\MCP\Transport\Infrastructure\RequestRouter
40 */
41 private RequestRouter $request_router;
42
43 /**
44 * Whether the bridge is currently running.
45 *
46 * @var bool
47 */
48 private bool $is_running = false;
49
50 /**
51 * Initialize the STDIO server bridge.
52 *
53 * @param \WP\MCP\Core\McpServer $server The MCP server to expose.
54 */
55 public function __construct( McpServer $server ) {
56 $this->server = $server;
57
58 // Create request router using server's infrastructure
59 $this->request_router = $this->create_request_router();
60 }
61
62 /**
63 * Create a request router for the server.
64 *
65 * @return \WP\MCP\Transport\Infrastructure\RequestRouter
66 */
67 private function create_request_router(): RequestRouter {
68 // Create transport context using server's infrastructure
69 $context = $this->server->create_transport_context();
70
71 return $context->request_router;
72 }
73
74 /**
75 * Start the STDIO server bridge.
76 *
77 * This method reads JSON-RPC messages from stdin and writes responses to stdout.
78 * It runs in a loop until terminated or until it receives a shutdown signal.
79 *
80 * @throws \RuntimeException If STDIO transport is disabled.
81 */
82 public function serve(): void {
83 /**
84 * Filters whether the STDIO transport is enabled.
85 *
86 * Return false to disable STDIO transport entirely. This prevents
87 * the WP-CLI `mcp-adapter serve` command from functioning.
88 *
89 * @since 0.3.0
90 *
91 * @param bool $enabled Whether STDIO transport is enabled. Default true.
92 */
93 $enable_serve = apply_filters( 'mcp_adapter_enable_stdio_transport', true );
94
95 if ( ! $enable_serve ) {
96 throw new \RuntimeException(
97 'The STDIO transport is disabled. Enable it by setting the "mcp_adapter_enable_stdio_transport" filter to true.'
98 );
99 }
100
101 $this->is_running = true;
102
103 // Log to stderr to keep stdout clean for MCP messages
104 $this->log_to_stderr( sprintf( 'MCP STDIO Bridge started for server: %s', $this->server->get_server_id() ) );
105
106 // Main server loop
107 while ( $this->is_running ) {
108 try {
109 // Read a line from stdin (blocking)
110 $input = fgets( STDIN );
111
112 if ( false === $input ) {
113 // EOF or error reading from stdin
114 break;
115 }
116
117 // Trim newline delimiter
118 $input = rtrim( $input, "\r\n" );
119
120 if ( empty( $input ) ) {
121 // Empty line, continue reading
122 continue;
123 }
124
125 // Process the request and get response
126 $response = $this->handle_request( $input );
127
128 // Write response to stdout with newline delimiter
129 if ( ! empty( $response ) ) {
130 // Use fwrite() for precise binary-safe JSON-RPC protocol communication.
131 // WP_CLI output functions would add formatting/prefixes that break MCP protocol.
132 // MCP requires exact control over stdout for machine-to-machine communication.
133 fwrite( STDOUT, $response . "\n" ); // phpcs:ignore
134 fflush( STDOUT );
135 }
136 } catch ( \Throwable $e ) {
137 // Log errors to stderr
138 $this->log_to_stderr( 'Error processing request: ' . $e->getMessage() );
139
140 $error_response = $this->encode_response(
141 JsonRpcResponseBuilder::create_error_response(
142 null,
143 array(
144 'code' => McpErrorFactory::INTERNAL_ERROR,
145 'message' => 'Internal error',
146 'data' => array(
147 'details' => $e->getMessage(),
148 ),
149 )
150 )
151 );
152
153 fwrite( STDOUT, $error_response . "\n" ); // phpcs:ignore
154 fflush( STDOUT );
155 }
156 }
157
158 $this->log_to_stderr( 'MCP STDIO Bridge stopped' );
159 }
160
161 /**
162 * Log a message to stderr.
163 *
164 * @param string $message The message to log.
165 */
166 private function log_to_stderr( string $message ): void {
167 fwrite( STDERR, "[MCP STDIO Bridge] $message\n" ); // phpcs:ignore
168 }
169
170 /**
171 * Handle a JSON-RPC request string and return a JSON-RPC response string.
172 *
173 * @param string $json_input The JSON-RPC request string.
174 *
175 * @return string The JSON-RPC response string (empty for notifications).
176 */
177 private function handle_request( string $json_input ): string {
178 try {
179 // Parse JSON-RPC request
180 $request = json_decode( $json_input, true );
181
182 if ( json_last_error() !== JSON_ERROR_NONE ) {
183 return $this->create_error_response(
184 null,
185 McpErrorFactory::PARSE_ERROR,
186 'Parse error',
187 'Invalid JSON was received by the server.'
188 );
189 }
190
191 // Validate JSON-RPC structure
192 if ( ! is_array( $request ) ) {
193 return $this->create_error_response(
194 null,
195 McpErrorFactory::INVALID_REQUEST,
196 'Invalid Request',
197 'The JSON sent is not a valid Request object.'
198 );
199 }
200
201 // Check for JSON-RPC version
202 if ( ! isset( $request['jsonrpc'] ) || '2.0' !== $request['jsonrpc'] ) {
203 return $this->create_error_response(
204 $request['id'] ?? null,
205 McpErrorFactory::INVALID_REQUEST,
206 'Invalid Request',
207 'The JSON-RPC version must be 2.0.'
208 );
209 }
210
211 // Extract request components
212 $method = $request['method'] ?? null;
213 $params = $request['params'] ?? array();
214 $id = $request['id'] ?? null;
215
216 if ( ! is_string( $method ) ) {
217 return $this->create_error_response(
218 $id,
219 McpErrorFactory::INVALID_REQUEST,
220 'Invalid Request',
221 'Method must be a string.'
222 );
223 }
224
225 // Convert params to array if it's an object
226 if ( is_object( $params ) ) {
227 $params = (array) $params;
228 }
229
230 if ( ! is_array( $params ) ) {
231 $params = array();
232 }
233
234 // Route the request to the appropriate handler
235 $result = $this->request_router->route_request(
236 $method,
237 $params,
238 $id,
239 'stdio'
240 );
241
242 // If this is a notification (no id), don't send a response
243 if ( null === $id ) {
244 return '';
245 }
246
247 // Format the response
248 return $this->format_response( $result, $id );
249 } catch ( \Throwable $e ) {
250 // Handle unexpected errors
251 return $this->create_error_response(
252 null,
253 McpErrorFactory::INTERNAL_ERROR,
254 'Internal error',
255 $e->getMessage()
256 );
257 }
258 }
259
260 /**
261 * Create a JSON-RPC error response.
262 *
263 * @param mixed $id The request ID (can be null).
264 * @param int $code The error code.
265 * @param string $message The error message.
266 * @param string $data Optional error data.
267 *
268 * @return string The JSON error response string.
269 */
270 private function create_error_response( $id, int $code, string $message, string $data = '' ): string {
271 $error = array(
272 'code' => $code,
273 'message' => $message,
274 );
275
276 if ( '' !== $data ) {
277 $error['data'] = $data;
278 }
279
280 return $this->encode_response( JsonRpcResponseBuilder::create_error_response( $id, $error ) );
281 }
282
283 /**
284 * Encode a JSON-RPC response to a string.
285 *
286 * @param array $response JSON-RPC response structure.
287 *
288 * @return string JSON-encoded response string.
289 */
290 private function encode_response( array $response ): string {
291 $json = wp_json_encode( $response, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
292
293 if ( false === $json ) {
294 // Fallback when JSON encoding fails - use constant for consistency.
295 return sprintf(
296 '{"jsonrpc":"2.0","error":{"code":%d,"message":"Internal error"},"id":null}',
297 McpErrorFactory::INTERNAL_ERROR
298 );
299 }
300
301 return $json;
302 }
303
304 /**
305 * Format a handler result as a JSON-RPC response.
306 *
307 * @param array $result The handler result.
308 * @param mixed $id The request ID.
309 *
310 * @return string The JSON-RPC response string.
311 */
312 private function format_response( array $result, $id ): string {
313 // Check if result contains an error
314 if ( isset( $result['error'] ) ) {
315 $error = $result['error'];
316
317 // Ensure error has required fields
318 $error_payload = array(
319 'code' => $error['code'] ?? McpErrorFactory::INTERNAL_ERROR,
320 'message' => $error['message'] ?? 'Internal error',
321 );
322
323 // Add data field if present
324 if ( isset( $error['data'] ) ) {
325 $error_payload['data'] = $error['data'];
326 }
327
328 return $this->encode_response( JsonRpcResponseBuilder::create_error_response( $id, $error_payload ) );
329 }
330
331 return $this->encode_response( JsonRpcResponseBuilder::create_success_response( $id, $result ) );
332 }
333
334 /**
335 * Stop the STDIO server bridge.
336 */
337 public function stop(): void {
338 $this->is_running = false;
339 }
340
341 /**
342 * Get the server this bridge is exposing.
343 *
344 * @return \WP\MCP\Core\McpServer
345 */
346 public function get_server(): McpServer {
347 return $this->server;
348 }
349 }
350