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 / Cli / StdioServerBridge.php

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

350 lines 9.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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