From d86e86545fa6c843b8223aaaa794122d6b92ef84 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Mon, 17 Aug 2026 16:22:09 -0400 Subject: [PATCH] MCP increment 3: the stdio bridge + admin Connect screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the MCP server one-click to connect (TIGERMCP.md §11 step 3). - bin/mcp-bridge.php — a ZERO-Node PHP stdio<->HTTP relay. MCP clients (Claude Desktop/Code, Cursor) launch it; it POSTs each stdin JSON-RPC line to the install's /mcp with a Bearer token and writes the response back. Config via env (TIGER_MCP_URL / TIGER_MCP_TOKEN or argv). Guards the stdout channel (only JSON-RPC out; notifications relay nothing; a transport failure / non-JSON becomes a JSON-RPC error with the request id). curl with a stream fallback; sends a User-Agent (WAF). - modules/mcp Connect screen — Mcp_AdminController (/mcp/admin, nav-registered under Settings), Mcp_Service_Settings (enable toggle → tiger.mcp.enabled), the view (enable switch; mint/list/revoke tokens reusing the core Tiger_Service_Token over /api; ready-to-paste mcpServers config for npx mcp-remote AND the PHP bridge, with a token field that rebuilds the config live; a download action serving the bridge). - Tests: bridge decision logic (relay / notification / transport-error / non-JSON / disabled-404) unit; the enable service (write flag + admin gate) and the Connect controller (view vars + bridge download) integration. Deployed + smoke-tested on dev. Co-Authored-By: Claude Opus 4.8 --- CAPABILITIES.md | 2 +- TIGERMCP.md | 25 ++- bin/mcp-bridge.php | 137 +++++++++++++ modules/mcp/Bootstrap.php | 24 ++- modules/mcp/configs/acl.ini | 12 +- modules/mcp/controllers/AdminController.php | 49 +++++ modules/mcp/languages/en/mcp.php | 10 + modules/mcp/services/Settings.php | 32 +++ modules/mcp/views/scripts/admin/index.phtml | 184 ++++++++++++++++++ .../Mcp/McpAdminControllerTest.php | 39 ++++ tests/Integration/Mcp/McpSettingsTest.php | 50 +++++ tests/Unit/Mcp/McpBridgeTest.php | 70 +++++++ 12 files changed, 617 insertions(+), 17 deletions(-) create mode 100644 bin/mcp-bridge.php create mode 100644 modules/mcp/controllers/AdminController.php create mode 100644 modules/mcp/languages/en/mcp.php create mode 100644 modules/mcp/services/Settings.php create mode 100644 modules/mcp/views/scripts/admin/index.phtml create mode 100644 tests/Integration/Mcp/McpAdminControllerTest.php create mode 100644 tests/Integration/Mcp/McpSettingsTest.php create mode 100644 tests/Unit/Mcp/McpBridgeTest.php diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 88e0167..4c9eeb3 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -294,7 +294,7 @@ - **CMS** (`cms`, app) · services: Menu, Page, Settings · `modules/cms` - **Code** (`code`, developer) · services: Code · `modules/code` - **Identity** (`identity`, plugin) · services: Identity · `modules/identity` -- **Mcp** (`mcp`, module) · `modules/mcp` +- **Mcp** (`mcp`, module) · services: Settings · `modules/mcp` - **Media** (`media`, plugin) · services: Media, Settings · `modules/media` - **Profile** (`profile`, plugin) · services: Address, Avatar, Base, Contact, Org, OrgAddress, OrgContact, OrgLogo, Security, User · `modules/profile` - **Register** (`register`, plugin) · services: Registration, Status · `modules/register` diff --git a/TIGERMCP.md b/TIGERMCP.md index fea95c6..e0090f8 100644 --- a/TIGERMCP.md +++ b/TIGERMCP.md @@ -8,14 +8,15 @@ the in-app agent read [TIGERAGENT.md](TIGERAGENT.md); for the sibling extension [TIGERSKILLS.md](TIGERSKILLS.md) (§7 frames Skills vs MCP); for the admin-screen template read [ADMIN.md](ADMIN.md). -> **Status: increment 1 BUILT (the `/mcp` server); the rest scoped.** The `modules/mcp` core module ships -> the JSON-RPC endpoint — `initialize` / `tools/list` / `tools/call` / `ping`, Bearer auth via the existing -> `ServiceFactory` path, `tools/list` reflected from `Tiger_Agent_Tools::catalog(role)`, `tools/call` -> proxied to `/api` — **OFF by default** (`tiger.mcp.enabled`). Still scoped, not built: tool `inputSchema` -> from Forms (increment 2), the stdio bridge + admin Connect screen (increment 3), scoped/org tokens + -> metering (increment 4). This doc is the design-of-record for all of it. First-increment shape: **inbound** -> (Tiger *is* an MCP server) over a **stdio bridge** to **one** endpoint, **`/mcp`**, a **core module OFF by -> default**. +> **Status: increments 1 + 3 BUILT; 2 + 4 scoped.** The `modules/mcp` core module ships the JSON-RPC +> endpoint (increment 1 — `initialize` / `tools/list` / `tools/call` / `ping`, Bearer auth, `tools/list` from +> `Tiger_Agent_Tools::catalog(role)`, `tools/call` proxied to `/api`) AND the connect experience (increment 3 +> — the zero-Node PHP **stdio bridge** `bin/mcp-bridge.php` + the admin **Connect screen** `/mcp/admin`: +> enable toggle, mint/list/revoke tokens, copy-paste `mcpServers` config for npx-`mcp-remote` or the PHP +> bridge, bridge download). **OFF by default** (`tiger.mcp.enabled`). Still scoped, not built: tool +> `inputSchema` from Forms (increment 2), scoped/org-scoped tokens + per-token metering (increment 4). This +> doc is the design-of-record for all of it. Shape: **inbound**, a **stdio bridge** to **one** endpoint, +> **`/mcp`**, a **core module OFF by default**. --- @@ -243,8 +244,12 @@ MCP** IA (TIGERSKILLS §6): `MCP ▸ Server/Access` (inbound, this doc) and `MCP ACL gate). Verified live: 404 disabled → `initialize` handshake → `tools/list` reflects the role surface. 2. **Tool `inputSchema`** — wire the `Tiger_OpenApi_Generator` Form→JSON-Schema mapper into `tools/list` so arguments are typed (not just a permissive object). -3. **The stdio bridge** — `bin/mcp-bridge.php` (zero-Node) + the admin **Connect** screen (enable toggle, - mint-token, copy-paste config). Document `mcp-remote` too. +3. **The stdio bridge + Connect screen — ✅ BUILT.** `bin/mcp-bridge.php` (zero-Node PHP stdio↔HTTP relay: + env `TIGER_MCP_URL`/`TIGER_MCP_TOKEN`, guards the stdout channel, JSON-RPC errors on transport failure) + + `Mcp_AdminController` `/mcp/admin` (the Connect screen: enable toggle via `Mcp_Service_Settings`, mint/ + list/revoke tokens via the core `Tiger_Service_Token`, ready-to-paste `mcpServers` config for both + `npx mcp-remote` and the PHP bridge, a `download` action that serves the bridge). Nav-registered under + Settings; `mcp-remote` documented as the Node alternative. 4. **Scoped tokens + metering** — the token allow-list/read-only flag (§7) and per-token rate-limit + cap + audit (§8). 5. **(later) Streamable HTTP niceties** — `Mcp-Session-Id` + SSE for notifications; **OAuth 2.1** for diff --git a/bin/mcp-bridge.php b/bin/mcp-bridge.php new file mode 100644 index 0000000..25f5c49 --- /dev/null +++ b/bin/mcp-bridge.php @@ -0,0 +1,137 @@ +#!/usr/bin/env php + HTTP bridge for Tiger's MCP server (TIGERMCP.md §6). + * + * MCP clients (Claude Desktop, Cursor, Claude Code) launch a LOCAL process and speak newline-delimited + * JSON-RPC over stdio. This relays each message to a Tiger install's `POST /mcp` with a Bearer token and + * writes the response back — so a user configures Tiger like any other MCP server, needing only PHP (no + * Node, no Composer). Config comes from the environment (or argv): + * + * TIGER_MCP_URL the install's /mcp endpoint, e.g. https://my-site.com/mcp [required] (or argv[1]) + * TIGER_MCP_TOKEN a Tiger personal access token (tgr_...) [recommended] (or argv[2]) + * + * ONLY JSON-RPC ever goes to STDOUT (the protocol channel); diagnostics go to STDERR. Notifications (the + * server answers 202 no-body) relay nothing; a transport failure or a non-JSON response becomes a JSON-RPC + * error carrying the request id, so the client never hangs. + */ + +/** True only when this file is the script being run (not when a test include()s it). */ +function mcp_bridge_is_main() +{ + return PHP_SAPI === 'cli' + && isset($_SERVER['argv'][0]) + && @realpath($_SERVER['argv'][0]) === @realpath(__FILE__); +} + +/** The read/relay loop: stdin JSON-RPC -> POST /mcp -> stdout. */ +function mcp_bridge_run(array $argv) +{ + $url = getenv('TIGER_MCP_URL') ?: ($argv[1] ?? ''); + $token = getenv('TIGER_MCP_TOKEN') ?: ($argv[2] ?? ''); + + if ($url === '') { + fwrite(STDERR, "mcp-bridge: set TIGER_MCP_URL (or pass the /mcp URL as the first argument)\n"); + return 1; + } + + $headers = [ + 'Content-Type: application/json', + 'Accept: application/json', + 'User-Agent: TigerMCPBridge/1.0', // an outbound UA is required — some WAFs 403 a UA-less request + ]; + if ($token !== '') { $headers[] = 'Authorization: Bearer ' . $token; } + + while (($line = fgets(STDIN)) !== false) { + $line = trim($line); + if ($line === '') { continue; } + + [$ok, $status, $body, $err] = mcp_bridge_post($url, $headers, $line); + $out = mcp_bridge_response($line, $ok, $status, $body, $err); + if ($out !== null) { + fwrite(STDOUT, $out . "\n"); + fflush(STDOUT); + } + } + return 0; +} + +/** + * Decide what (if anything) to write to stdout for one message + its HTTP result. Pure — the unit-tested + * heart of the bridge. + * + * @return string|null the JSON-RPC line to emit, or null to emit nothing (a notification) + */ +function mcp_bridge_response($requestLine, $ok, $status, $body, $err) +{ + $id = null; + $req = json_decode((string) $requestLine, true); + if (is_array($req) && array_key_exists('id', $req)) { $id = $req['id']; } + + // Transport failure (no HTTP at all). + if (!$ok) { + return $id === null ? null : mcp_bridge_error($id, -32000, 'bridge transport error: ' . $err); + } + // A notification: the server accepts it with 202 and no body → relay nothing. + if ((int) $status === 202 || trim((string) $body) === '') { + return null; + } + // Guard the protocol channel: a non-JSON response (a WAF/HTML error page) must never reach stdout. + $t = ltrim((string) $body); + if ($t === '' || ($t[0] !== '{' && $t[0] !== '[')) { + return $id === null ? null : mcp_bridge_error($id, -32000, 'bridge: non-JSON response (HTTP ' . (int) $status . ')'); + } + return (string) $body; +} + +/** POST $payload to $url. Returns [ok(bool), status(int), body(string), error(string)]. */ +function mcp_bridge_post($url, array $headers, $payload) +{ + if (function_exists('curl_init')) { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 300, + CURLOPT_CONNECTTIMEOUT => 15, + ]); + $body = curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = (string) curl_error($ch); + curl_close($ch); + return $body === false ? [false, 0, '', ($err ?: 'curl failed')] : [true, $status, (string) $body, '']; + } + + // No curl → stream context fallback (needs allow_url_fopen). + $ctx = stream_context_create(['http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", $headers), + 'content' => $payload, + 'timeout' => 300, + 'ignore_errors' => true, + ]]); + $body = @file_get_contents($url, false, $ctx); + if ($body === false) { return [false, 0, '', 'stream request failed (no curl, allow_url_fopen?)']; } + // Status: use the 8.4+ API when present; else default 200 (an empty body already flags a 202 + // notification, and non-JSON is caught by content) — avoids the deprecated $http_response_header. + $status = 200; + if (function_exists('http_get_last_response_headers')) { + $h = http_get_last_response_headers(); + if (isset($h[0]) && preg_match('#\s(\d{3})\s#', (string) $h[0], $m)) { $status = (int) $m[1]; } + } + return [true, $status, (string) $body, '']; +} + +/** A JSON-RPC error line. */ +function mcp_bridge_error($id, $code, $message) +{ + return json_encode(['jsonrpc' => '2.0', 'id' => $id, 'error' => ['code' => (int) $code, 'message' => (string) $message]]); +} + +if (mcp_bridge_is_main()) { + exit(mcp_bridge_run($_SERVER['argv'])); +} diff --git a/modules/mcp/Bootstrap.php b/modules/mcp/Bootstrap.php index bc34e69..9def21f 100644 --- a/modules/mcp/Bootstrap.php +++ b/modules/mcp/Bootstrap.php @@ -4,15 +4,29 @@ /** * MCP module bootstrap — Tiger as an MCP server (TIGERMCP.md). * - * Increment 1 ships just the server: the module exists so its controller is dispatchable and its - * configs/ (routes.ini → /mcp, acl.ini → public controller) are picked up by the core globs. The endpoint - * itself is OFF by default (`tiger.mcp.enabled`, gated in Mcp_ServerController). The admin Connect screen + - * the enable toggle + the zero-Node stdio bridge come in increment 3; scoped tokens + metering in - * increment 4 (TIGERMCP.md §11). + * The module exists so its controllers are dispatchable and its configs/ (routes.ini → /mcp, acl.ini) are + * picked up by the core globs. The `/mcp` endpoint is OFF by default (`tiger.mcp.enabled`, gated in + * Mcp_ServerController); the admin Connect screen (/mcp/admin) turns it on, mints a token, and hands out the + * client config + the zero-Node stdio bridge. Scoped/org tokens + metering are increment 4 (TIGERMCP.md §11). * * Extending Zend_Application_Module_Bootstrap gives the module its resource autoloader; the /mcp route rides * the module routes.ini ingester (Tiger_Routing_ModuleRoutes). */ class Mcp_Bootstrap extends Zend_Application_Module_Bootstrap { + /** List the MCP Connect screen under the admin Settings tree (ACL-gated to Mcp_AdminController = admin+). */ + protected function _initAdminSettings() + { + if (!class_exists('Tiger_Admin_Settings')) { + return; + } + Tiger_Admin_Settings::register([ + 'key' => 'mcp', + 'label' => 'MCP Server', + 'icon' => 'fa-plug', + 'href' => '/mcp/admin', + 'resource' => 'Mcp_AdminController', + 'order' => 47, + ]); + } } diff --git a/modules/mcp/configs/acl.ini b/modules/mcp/configs/acl.ini index c19c6b8..7ca9c93 100644 --- a/modules/mcp/configs/acl.ini +++ b/modules/mcp/configs/acl.ini @@ -6,11 +6,21 @@ ; (deny-by-default). A no-token / guest caller sees only guest-allowed tools and can mutate nothing. The ; endpoint is also OFF by default (tiger.mcp.enabled) — this rule only governs reachability of the shell. [production] -acl.resources.mcp_server.resource = "Mcp_ServerController" +acl.resources.mcp_server.resource = "Mcp_ServerController" +acl.resources.mcp_admin_ctrl.resource = "Mcp_AdminController" ; the Connect screen (admin) +acl.resources.mcp_settings_svc.resource = "Mcp_Service_Settings" ; the enable toggle acl.rules.pub_mcp.role = "guest" acl.rules.pub_mcp.resource = "Mcp_ServerController" acl.rules.pub_mcp.permission = "allow" + +acl.rules.mcp_admin_ctrl.role = "admin" +acl.rules.mcp_admin_ctrl.resource = "Mcp_AdminController" +acl.rules.mcp_admin_ctrl.permission = "allow" + +acl.rules.mcp_settings_svc.role = "admin" +acl.rules.mcp_settings_svc.resource = "Mcp_Service_Settings" +acl.rules.mcp_settings_svc.permission = "allow" [staging : production] [testing : production] [development : production] diff --git a/modules/mcp/controllers/AdminController.php b/modules/mcp/controllers/AdminController.php new file mode 100644 index 0000000..8dcb95a --- /dev/null +++ b/modules/mcp/controllers/AdminController.php @@ -0,0 +1,49 @@ +view->title = 'MCP Server — Tiger Admin'; + $this->view->enabled = Tiger_Mcp::isEnabled(); + $this->view->mcpUrl = $this->_baseUrl() . '/mcp'; + $this->view->bridge = TIGER_CORE_PATH . '/bin/mcp-bridge.php'; + $this->view->protocol = Tiger_Mcp::PROTOCOL_VERSION; + } + + /** Stream the zero-Node PHP stdio bridge as a download (the user runs it locally for stdio clients). */ + public function downloadAction() + { + $this->_helper->layout->disableLayout(); + $this->_helper->viewRenderer->setNoRender(true); + + $file = TIGER_CORE_PATH . '/bin/mcp-bridge.php'; + $resp = $this->getResponse(); + if (!is_file($file)) { $resp->setHttpResponseCode(404); return; } + + $resp->setHeader('Content-Type', 'text/x-php; charset=UTF-8', true); + $resp->setHeader('Content-Disposition', 'attachment; filename="mcp-bridge.php"', true); + echo file_get_contents($file); + } + + /** The site's public base URL (scheme + host) for the ready-to-paste config. */ + protected function _baseUrl() + { + $req = $this->getRequest(); + $scheme = ((defined('HTTPS') && HTTPS) || $req->getScheme() === 'https') ? 'https' : 'http'; + return $scheme . '://' . $req->getHttpHost(); + } +} diff --git a/modules/mcp/languages/en/mcp.php b/modules/mcp/languages/en/mcp.php new file mode 100644 index 0000000..2a512aa --- /dev/null +++ b/modules/mcp/languages/en/mcp.php @@ -0,0 +1,10 @@ + 'MCP server enabled.', + 'mcp.settings.disabled' => 'MCP server disabled.', +]; diff --git a/modules/mcp/services/Settings.php b/modules/mcp/services/Settings.php new file mode 100644 index 0000000..5134a77 --- /dev/null +++ b/modules/mcp/services/Settings.php @@ -0,0 +1,32 @@ +_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + + $on = !empty($params['enabled']) && $params['enabled'] !== '0' && $params['enabled'] !== 'false'; + try { + (new Tiger_Model_Config())->set(Tiger_Model_Config::SCOPE_GLOBAL, '', Tiger_Mcp::CONFIG_ENABLED, $on ? '1' : '0'); + $this->_success(['enabled' => $on], $on ? 'mcp.settings.enabled' : 'mcp.settings.disabled'); + } catch (Throwable $e) { + $this->_error(APPLICATION_ENV !== 'production' ? $e->getMessage() : 'core.api.error.general'); + } + } +} diff --git a/modules/mcp/views/scripts/admin/index.phtml b/modules/mcp/views/scripts/admin/index.phtml new file mode 100644 index 0000000..5b32017 --- /dev/null +++ b/modules/mcp/views/scripts/admin/index.phtml @@ -0,0 +1,184 @@ +mcpUrl; +$bridge = (string) $this->bridge; +?> +
+
+

MCP Server

+

Let an external AI client (Claude Desktop/Code, Cursor, ChatGPT) drive this site through Tiger's /api — with the same permissions a signed-in user of the token's role has. Off by default; every action is ACL-gated and audited.

+
+
+ +
+ +
+
+ + +
+
Enable the MCP endpoint
+
+
+ enabled ? 'checked' : '' ?>> + +
+

When on, POST /mcp accepts MCP (JSON-RPC) from any client presenting a valid token. When off, it returns 404. A caller with no token is a guest and can reach only guest-allowed read tools.

+ +
+
+ + +
+
+ Access tokens + +
+
+
+
Copy this now — it's shown only once.
+
+ + +
+
+
+ +
+
+ +
+ +
+ +
+
Connect a client
+
+ + + + +
+
+

Add to your client's mcpServers config (Claude Desktop, Cursor, …). Uses the community mcp-remote bridge — nothing to install.

+

+            
+          
+
+

No Node? Download mcp-bridge.php, save it on your machine, and set the path below. Needs only PHP.

+

+            
+          
+
+ +
+

Or test it directly:

+
curl -X POST escape($mcpUrl) ?> \
+  -H "Content-Type: application/json" \
+  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
+
+
+
+
+ + diff --git a/tests/Integration/Mcp/McpAdminControllerTest.php b/tests/Integration/Mcp/McpAdminControllerTest.php new file mode 100644 index 0000000..6bb4cab --- /dev/null +++ b/tests/Integration/Mcp/McpAdminControllerTest.php @@ -0,0 +1,39 @@ +loginAs('admin'); + $res = $this->dispatchAction(Mcp_AdminController::class, 'index', [], 'GET'); + $this->assertSame(200, $res->getHttpResponseCode()); + + $view = $this->controller()->view; + $this->assertStringContainsString('MCP', $view->title); + $this->assertStringEndsWith('/mcp', $view->mcpUrl, 'the config points at the /mcp endpoint'); + $this->assertStringEndsWith('bin/mcp-bridge.php', $view->bridge, 'the download path is the shipped bridge'); + } + + #[Test] + public function download_streams_the_stdio_bridge(): void + { + $this->loginAs('admin'); + $this->dispatchAction(Mcp_AdminController::class, 'download', [], 'GET'); + $this->assertStringContainsString('mcp_bridge_run', $this->echoed, 'the zero-Node bridge script is served'); + $this->assertStringStartsWith('#!/usr/bin/env php', $this->echoed); + } +} diff --git a/tests/Integration/Mcp/McpSettingsTest.php b/tests/Integration/Mcp/McpSettingsTest.php new file mode 100644 index 0000000..09651fe --- /dev/null +++ b/tests/Integration/Mcp/McpSettingsTest.php @@ -0,0 +1,50 @@ + $method] + $params))->getResponse(); + } + + #[Test] + public function save_writes_the_enabled_flag_to_config(): void + { + $this->loginAs('admin'); + $cfg = new Tiger_Model_Config(); + + $on = $this->call('save', ['enabled' => 1]); + $this->assertSame(1, (int) $on->result); + $this->assertTrue($on->data['enabled']); + $this->assertSame('1', (string) $cfg->get(Tiger_Model_Config::SCOPE_GLOBAL, '', Tiger_Mcp::CONFIG_ENABLED)); + + $off = $this->call('save', ['enabled' => 0]); + $this->assertSame(1, (int) $off->result); + $this->assertFalse($off->data['enabled']); + $this->assertSame('0', (string) $cfg->get(Tiger_Model_Config::SCOPE_GLOBAL, '', Tiger_Mcp::CONFIG_ENABLED)); + } + + #[Test] + public function save_is_denied_for_a_non_admin(): void + { + $this->login('u1', 'org-test', 'user'); + $res = $this->call('save', ['enabled' => 1]); + $this->assertSame(0, (int) $res->result); + $this->assertStringContainsString('not_allowed', json_encode($res->messages)); + } +} diff --git a/tests/Unit/Mcp/McpBridgeTest.php b/tests/Unit/Mcp/McpBridgeTest.php new file mode 100644 index 0000000..d947b62 --- /dev/null +++ b/tests/Unit/Mcp/McpBridgeTest.php @@ -0,0 +1,70 @@ +HTTP relay's decision logic (mcp_bridge_response), tested pure: which + * message (if any) reaches stdout for a given request + HTTP result. Including the script defines its + * functions without running the loop (mcp_bridge_is_main() is false under PHPUnit). + */ +final class McpBridgeTest extends UnitTestCase +{ + public static function setUpBeforeClass(): void + { + require_once dirname(__DIR__, 3) . '/bin/mcp-bridge.php'; + } + + #[Test] + public function it_relays_a_json_response_verbatim(): void + { + $body = '{"jsonrpc":"2.0","id":1,"result":{"ok":true}}'; + $this->assertSame($body, mcp_bridge_response('{"id":1,"method":"ping"}', true, 200, $body, '')); + } + + #[Test] + public function a_notification_relays_nothing(): void + { + $this->assertNull(mcp_bridge_response('{"method":"notifications/initialized"}', true, 202, '', '')); + $this->assertNull(mcp_bridge_response('{"method":"notifications/initialized"}', true, 200, '', ''), 'empty body → nothing'); + } + + #[Test] + public function a_transport_failure_becomes_a_jsonrpc_error_with_the_request_id(): void + { + $out = mcp_bridge_response('{"id":5,"method":"tools/list"}', false, 0, '', 'connect timeout'); + $err = json_decode($out, true); + $this->assertSame(5, $err['id']); + $this->assertSame(-32000, $err['error']['code']); + $this->assertStringContainsString('connect timeout', $err['error']['message']); + } + + #[Test] + public function a_transport_failure_on_a_notification_stays_silent(): void + { + // no id → nothing to answer to + $this->assertNull(mcp_bridge_response('{"method":"notifications/x"}', false, 0, '', 'boom')); + } + + #[Test] + public function a_non_json_response_never_corrupts_the_stream(): void + { + $out = mcp_bridge_response('{"id":9,"method":"ping"}', true, 502, 'Bad Gateway', ''); + $err = json_decode($out, true); + $this->assertSame(9, $err['id']); + $this->assertSame(-32000, $err['error']['code']); + $this->assertStringContainsString('non-JSON', $err['error']['message']); + } + + #[Test] + public function a_disabled_404_json_error_body_is_relayed(): void + { + // /mcp when off returns a JSON-RPC error body — it starts with '{' so it is passed through. + $body = '{"jsonrpc":"2.0","id":null,"error":{"code":-32601,"message":"MCP is not enabled"}}'; + $this->assertSame($body, mcp_bridge_response('{"id":1,"method":"ping"}', true, 404, $body, '')); + } +}