From 3b346b4c0e78b172dbed8e65ae19664da35ef586 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Mon, 17 Aug 2026 15:27:02 -0400 Subject: [PATCH] MCP increment 1: the /mcp server (core module, off by default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tiger as an MCP server (TIGERMCP.md §11 step 1). An external AI client drives the install through the SAME token-authenticated, ACL-gated /api surface — reach, not capability. - Tiger_Mcp — facade: the enable gate (tiger.mcp.enabled, OFF by default) + protocol version negotiation. - Tiger_Mcp_Server — the JSON-RPC 2.0 engine: initialize / tools/list / tools/call / ping, notifications ignored, batch refused. tools/list = Tiger_Agent_Tools::catalog (role) serialized to MCP tools (name = module__service__method, permissive inputSchema for now — Forms typing is increment 2); tools/call parses the name and runs the op through a dispatch seam, wrapping the /api envelope as MCP content (isError on result=0). - modules/mcp — Mcp_ServerController (the /mcp HTTP surface: reads the JSON-RPC body, resolves the Bearer token identity else session — mirrors ServiceFactory — and dispatches tools/call via ServiceFactory as that identity), routes.ini (/mcp, via the module-routes ingester), acl.ini (public controller like ApiController; the token + each service's own ACL do the real gating). OFF by default → /mcp 404s. - Tests: engine unit (lifecycle, name mapping, envelope wrapping, batch/notification/ unknown) + controller integration (404 disabled, initialize serverInfo, tools/list reflects the admin catalog). Verified live on dev over HTTP. Co-Authored-By: Claude Opus 4.8 --- CAPABILITIES.md | 8 +- TIGERMCP.md | 21 ++- bin/build-capabilities.php | 1 + library/Tiger/Mcp.php | 57 +++++++ library/Tiger/Mcp/Server.php | 151 +++++++++++++++++++ modules/mcp/Bootstrap.php | 18 +++ modules/mcp/configs/acl.ini | 16 ++ modules/mcp/configs/routes.ini | 15 ++ modules/mcp/controllers/ServerController.php | 103 +++++++++++++ tests/Integration/Mcp/McpControllerTest.php | 92 +++++++++++ tests/Unit/Mcp/McpServerTest.php | 126 ++++++++++++++++ 11 files changed, 600 insertions(+), 8 deletions(-) create mode 100644 library/Tiger/Mcp.php create mode 100644 library/Tiger/Mcp/Server.php create mode 100644 modules/mcp/Bootstrap.php create mode 100644 modules/mcp/configs/acl.ini create mode 100644 modules/mcp/configs/routes.ini create mode 100644 modules/mcp/controllers/ServerController.php create mode 100644 tests/Integration/Mcp/McpControllerTest.php create mode 100644 tests/Unit/Mcp/McpServerTest.php diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 11ba212..88e0167 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -5,7 +5,7 @@ > before assuming something isn't built. `@api` = stable to build on; `@internal` = may change. > Grouped by **capability** (across layers), not by directory. -**176 classes** across **31 capabilities** · **17 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). +**178 classes** across **32 capabilities** · **18 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). ## Capabilities (`library/Tiger`) @@ -198,6 +198,11 @@ - **Tiger_Skill_Source_SkillsDir** `@api` — the adapter for the common "collection" layout: a repo whose skills live as `//SKILL.md` folders (e.g. · `library/Tiger/Skill/Source/SkillsDir.php` - **Tiger_Skill_Source_Url** `@api` — the "paste a GitHub URL" adapter. · `library/Tiger/Skill/Source/Url.php` +### MCP server + +- **Tiger_Mcp** `@api` — the facade for the MCP server (Tiger as an MCP server; see TIGERMCP.md). · `library/Tiger/Mcp.php` +- **Tiger_Mcp_Server** `@api` — the MCP JSON-RPC 2.0 protocol engine (transport-agnostic; TIGERMCP.md §3-5). · `library/Tiger/Mcp/Server.php` + ### Scheduling - **Tiger_Model_ScheduleRun** `@api` — ScheduleRun — one execution record of a Tiger_Schedule job (the run log + the "last run" state). · `library/Tiger/Model/ScheduleRun.php` @@ -289,6 +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` - **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 93e9b08..fea95c6 100644 --- a/TIGERMCP.md +++ b/TIGERMCP.md @@ -8,10 +8,14 @@ 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: SCOPED, not built.** This is the design-of-record so we don't relitigate it when the code -> lands. The hard prerequisites already exist (§2); what's left is a thin protocol adapter (§8). First -> increment is **inbound** (Tiger *is* an MCP server) over a **stdio bridge** to **one** endpoint, -> **`/mcp`**, shipped as a **core module that is OFF by default**. +> **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**. --- @@ -231,9 +235,12 @@ MCP** IA (TIGERSKILLS §6): `MCP ▸ Server/Access` (inbound, this doc) and `MCP ## 11. Build order (increments) -1. **The `/mcp` server** — `modules/mcp` (off by default): the JSON-RPC endpoint, `initialize` / `tools/list` - / `tools/call` / `ping`, Bearer auth via the existing ServiceFactory path, request/response transport. - `tools/list` from `Tiger_Agent_Tools::catalog(role)`; `tools/call` proxied to `/api`. +1. **The `/mcp` server — ✅ BUILT.** `modules/mcp` (off by default): `Tiger_Mcp` (enable gate + version + negotiation) + `Tiger_Mcp_Server` (the JSON-RPC engine — `initialize` / `tools/list` / `tools/call` / + `ping`) + `Mcp_ServerController` (the `/mcp` HTTP surface: Bearer-or-session identity, request/response). + `tools/list` from `Tiger_Agent_Tools::catalog(role)`; `tools/call` proxied to `/api` via `ServiceFactory`. + Route ingested from `modules/mcp/configs/routes.ini`; controller public in `acl.ini` (token + per-service + 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, diff --git a/bin/build-capabilities.php b/bin/build-capabilities.php index ab0c332..fcde45a 100644 --- a/bin/build-capabilities.php +++ b/bin/build-capabilities.php @@ -46,6 +46,7 @@ ['Sessions', ['Tiger_Session']], ['AI agent', ['Tiger_Agent']], ['Agent skills', ['Tiger_Skill_']], + ['MCP server', ['Tiger_Mcp']], ['Scheduling', ['Tiger_Schedule', 'Tiger_Model_ScheduleRun']], ['Backup', ['Tiger_Backup', 'Tiger_Model_Backup']], ['Code area', ['Tiger_Code', 'Tiger_Model_Code']], diff --git a/library/Tiger/Mcp.php b/library/Tiger/Mcp.php new file mode 100644 index 0000000..7dfc0f4 --- /dev/null +++ b/library/Tiger/Mcp.php @@ -0,0 +1,57 @@ +get('tiger') && $cfg->tiger->get('mcp')) ? $cfg->tiger->mcp : null; + $v = $mcp ? $mcp->get('enabled') : null; + return $v !== null && (string) $v !== '0' && strtolower((string) $v) !== 'false'; + } catch (Throwable $e) { + return false; + } + } + + /** + * Negotiate the protocol version: echo the client's requested version if we support it, else respond + * with our default (the spec requires responding with a version we do support). + * + * @param string $requested the client's `protocolVersion` + * @return string + */ + public static function negotiateVersion($requested) + { + return in_array((string) $requested, self::SUPPORTED_VERSIONS, true) + ? (string) $requested + : self::PROTOCOL_VERSION; + } +} diff --git a/library/Tiger/Mcp/Server.php b/library/Tiger/Mcp/Server.php new file mode 100644 index 0000000..701dc9c --- /dev/null +++ b/library/Tiger/Mcp/Server.php @@ -0,0 +1,151 @@ + Tiger_Mcp::negotiateVersion($params['protocolVersion'] ?? ''), + 'capabilities' => ['tools' => new stdClass()], // tools supported (no listChanged) + 'serverInfo' => ['name' => 'Tiger', 'version' => Tiger_Version::VERSION], + 'instructions' => 'Tiger platform MCP server. Each tool is one of this token\'s role-allowed ' + . '/api operations, named "____"; call one with its ' + . 'form fields as arguments. Reads are safe; writes run validate→transaction ' + . 'and are gated by the same ACL a human of this role has.', + ]; + } + + /** tools/list = the role-filtered /api catalog, one MCP tool per operation. */ + protected static function _toolsList($role) + { + $tools = []; + foreach (Tiger_Agent_Tools::catalog($role) as $module => $ops) { + foreach ($ops as $op) { + $tools[] = [ + 'name' => self::toolName((string) $module, (string) $op['service'], (string) $op['method']), + 'description' => (string) ($op['summary'] ?? ''), + 'inputSchema' => ['type' => 'object'], // permissive in v1; typed from the Form in increment 2 + ]; + } + } + return ['tools' => $tools]; + } + + /** tools/call → dispatch the named /api op through the seam, wrap the envelope as MCP content. */ + protected static function _toolsCall(array $params, callable $dispatch) + { + $name = (string) ($params['name'] ?? ''); + $args = (isset($params['arguments']) && is_array($params['arguments'])) ? $params['arguments'] : []; + + $t = self::parseToolName($name); + if ($t === null) { + return self::_toolError('Unknown tool: ' . ($name !== '' ? $name : '(none)')); + } + try { + $env = $dispatch($t[0], $t[1], $t[2], $args); // the /api Tiger_Model_ResponseObject + } catch (Throwable $e) { + return self::_toolError('Dispatch failed'); + } + + $ok = is_object($env) && (int) ($env->result ?? 0) === 1; + $text = json_encode([ + 'result' => $ok ? 1 : 0, + 'data' => is_object($env) ? ($env->data ?? null) : null, + 'messages' => is_object($env) ? ($env->messages ?? []) : [], + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + return ['content' => [['type' => 'text', 'text' => $text]], 'isError' => !$ok]; + } + + /** The MCP tool name for an /api op: `____` (module/service are alpha). */ + public static function toolName($module, $service, $method) + { + return $module . '__' . $service . '__' . $method; + } + + /** + * Reverse a tool name → [module, service, method], or null if malformed. `explode(…, 3)` keeps any + * underscores that belong to the method name (module/service are alpha, so the first two `__` delimit). + * + * @param string $name + * @return array{0:string,1:string,2:string}|null + */ + public static function parseToolName($name) + { + $p = explode('__', (string) $name, 3); + if (count($p) !== 3 || $p[0] === '' || $p[1] === '' || $p[2] === '') { return null; } + return $p; + } + + /** A tool-execution error is a SUCCESSFUL JSON-RPC result with isError=true (not a protocol error). */ + protected static function _toolError($message) + { + return ['content' => [['type' => 'text', 'text' => (string) $message]], 'isError' => true]; + } + + protected static function _result($id, $result) + { + return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $result]; + } + + protected static function _error($id, $code, $message) + { + return ['jsonrpc' => '2.0', 'id' => $id, 'error' => ['code' => (int) $code, 'message' => (string) $message]]; + } +} diff --git a/modules/mcp/Bootstrap.php b/modules/mcp/Bootstrap.php new file mode 100644 index 0000000..bc34e69 --- /dev/null +++ b/modules/mcp/Bootstrap.php @@ -0,0 +1,18 @@ + Mcp_ServerController::indexAction (ingested by +; Tiger_Routing_ModuleRoutes — declarative, no Bootstrap code). Off by default regardless +; (tiger.mcp.enabled, gated in the controller). +[production] +resources.router.routes.mcp.type = "Zend_Controller_Router_Route_Static" +resources.router.routes.mcp.route = "mcp" +resources.router.routes.mcp.defaults.module = "mcp" +resources.router.routes.mcp.defaults.controller = "server" +resources.router.routes.mcp.defaults.action = "index" +[staging : production] +[testing : production] +[development : production] diff --git a/modules/mcp/controllers/ServerController.php b/modules/mcp/controllers/ServerController.php new file mode 100644 index 0000000..e11e4dc --- /dev/null +++ b/modules/mcp/controllers/ServerController.php @@ -0,0 +1,103 @@ +_helper->viewRenderer->setNoRender(true); + if (Zend_Layout::getMvcInstance()) { + Zend_Layout::getMvcInstance()->disableLayout(); // else the theme layout would wrap our JSON + } + $this->getResponse()->setHeader('Content-Type', 'application/json; charset=UTF-8', true); + } + + /** The single MCP endpoint: one JSON-RPC request in, one response out (Streamable HTTP, request/response). */ + public function indexAction() + { + $resp = $this->getResponse(); + + // OFF by default — the endpoint does not exist until an admin enables it. + if (!Tiger_Mcp::isEnabled()) { + $resp->setHttpResponseCode(404); + $this->_emit(['jsonrpc' => '2.0', 'id' => null, 'error' => ['code' => -32601, 'message' => 'MCP is not enabled']]); + return; + } + + $msg = json_decode($this->_rawBody(), true); + if (!is_array($msg)) { + $resp->setHttpResponseCode(400); + $this->_emit(['jsonrpc' => '2.0', 'id' => null, 'error' => ['code' => -32700, 'message' => 'Parse error']]); + return; + } + + $identity = $this->_identity(); + $role = ($identity && !empty($identity->role)) ? (string) $identity->role : 'guest'; + + $out = Tiger_Mcp_Server::handle($msg, $role, function ($module, $service, $method, $args) { + // Dispatch the /api op through the SAME gateway the browser + Forge use — as the resolved + // identity (a fresh request carries the Bearer from $_SERVER; else ServiceFactory falls back to + // the identity written to Zend_Auth above). The target service's own ACL + form-validate + + // transaction all run unchanged. + $req = new Zend_Controller_Request_Http(); + $req->setParam('svc_module', $module); + $req->setParam('svc_service', $service); + $req->setParam('svc_action', $method); + foreach ((array) $args as $k => $v) { $req->setParam((string) $k, $v); } + return (new Tiger_Ajax_ServiceFactory($req))->getResponse(); + }); + + if ($out === null) { + $resp->setHttpResponseCode(202); // a notification → accepted, no body + return; + } + $resp->setHttpResponseCode(200); + $this->_emit($out); + } + + /** + * The request identity: a Bearer token (stateless, wins) resolved to an identity and written to + * Zend_Auth so the downstream dispatch sees it; else the session identity. Mirrors ServiceFactory — a + * token request that presents an INVALID token stays guest (never falls back to a session). + * + * @return object|null the identity, or null (guest) + */ + protected function _identity() + { + $h = (string) $this->getRequest()->getHeader('Authorization'); + if (preg_match('/^\s*Bearer\s+(\S+)/i', $h, $m)) { + $id = (new Tiger_Service_Authentication())->identityFromToken($m[1]); + if ($id !== null) { + $auth = Zend_Auth::getInstance(); + if (!($auth->getStorage() instanceof Zend_Auth_Storage_NonPersistent)) { + $auth->setStorage(new Zend_Auth_Storage_NonPersistent()); + } + $auth->getStorage()->write($id); + } + return $id; + } + return Zend_Auth::getInstance()->getIdentity(); + } + + /** Raw request body (a seam so tests can inject a JSON-RPC message without php://input). */ + protected function _rawBody() + { + return (string) file_get_contents('php://input'); + } + + /** Emit a JSON payload as the response body. */ + protected function _emit(array $payload) + { + echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } +} diff --git a/tests/Integration/Mcp/McpControllerTest.php b/tests/Integration/Mcp/McpControllerTest.php new file mode 100644 index 0000000..42ee408 --- /dev/null +++ b/tests/Integration/Mcp/McpControllerTest.php @@ -0,0 +1,92 @@ +origConfig = Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null; + FakeMcpController::$body = ''; + } + + protected function tearDown(): void + { + if ($this->origConfig !== null) { Zend_Registry::set('Zend_Config', $this->origConfig); } + parent::tearDown(); + } + + private function enableMcp(): void + { + $arr = $this->origConfig ? $this->origConfig->toArray() : []; + $arr['tiger']['mcp']['enabled'] = 1; + Zend_Registry::set('Zend_Config', new Zend_Config($arr, true)); + } + + private function post(array $msg): array + { + FakeMcpController::$body = json_encode($msg); + $res = $this->dispatchAction(FakeMcpController::class, 'index', [], 'POST'); + return [$res->getHttpResponseCode(), json_decode($this->echoed, true)]; + } + + #[Test] + public function the_endpoint_is_404_when_disabled(): void + { + // default: tiger.mcp.enabled is off → the endpoint does not exist. + [$code] = $this->post(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'ping']); + $this->assertSame(404, $code, 'off by default'); + } + + #[Test] + public function initialize_returns_serverinfo_when_enabled(): void + { + $this->enableMcp(); + [$code, $out] = $this->post(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => []]); + $this->assertSame(200, $code); + $this->assertSame('Tiger', $out['result']['serverInfo']['name']); + $this->assertArrayHasKey('tools', $out['result']['capabilities']); + } + + #[Test] + public function tools_list_reflects_the_role_catalog_when_enabled(): void + { + $this->enableMcp(); + $this->loginAs('admin'); + [$code, $out] = $this->post(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/list']); + $this->assertSame(200, $code); + $this->assertNotEmpty($out['result']['tools'], 'an admin sees a non-empty tool surface'); + $this->assertMatchesRegularExpression('/^[a-z0-9-]+__[a-z]+__[a-z0-9_]+$/i', $out['result']['tools'][0]['name']); + $this->assertArrayHasKey('inputSchema', $out['result']['tools'][0]); + } +} + +/** Test double: inject the JSON-RPC body without php://input. */ +class FakeMcpController extends Mcp_ServerController +{ + public static $body = ''; + protected function _rawBody() + { + return self::$body; + } +} diff --git a/tests/Unit/Mcp/McpServerTest.php b/tests/Unit/Mcp/McpServerTest.php new file mode 100644 index 0000000..1fdec77 --- /dev/null +++ b/tests/Unit/Mcp/McpServerTest.php @@ -0,0 +1,126 @@ + null); + } + + #[Test] + public function initialize_advertises_tools_and_serverinfo(): void + { + $r = $this->handle(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => ['protocolVersion' => '2025-06-18']]); + $this->assertSame('2.0', $r['jsonrpc']); + $this->assertSame(1, $r['id']); + $this->assertSame('2025-06-18', $r['result']['protocolVersion'], 'echoes a supported version'); + $this->assertArrayHasKey('tools', $r['result']['capabilities']); + $this->assertSame('Tiger', $r['result']['serverInfo']['name']); + $this->assertNotEmpty($r['result']['serverInfo']['version']); + } + + #[Test] + public function initialize_falls_back_to_our_version_for_an_unknown_one(): void + { + $r = $this->handle(['id' => 1, 'method' => 'initialize', 'params' => ['protocolVersion' => '1999-01-01']]); + $this->assertSame(Tiger_Mcp::PROTOCOL_VERSION, $r['result']['protocolVersion']); + } + + #[Test] + public function ping_returns_an_empty_result(): void + { + $r = $this->handle(['id' => 7, 'method' => 'ping']); + $this->assertSame(7, $r['id']); + $this->assertEquals(new \stdClass(), $r['result']); + } + + #[Test] + public function a_notification_gets_no_response(): void + { + $this->assertNull($this->handle(['method' => 'notifications/initialized'])); + $this->assertNull($this->handle(['method' => 'notifications/cancelled'])); + $this->assertNull($this->handle(['method' => 'something/unknown']), 'an unknown NOTIFICATION (no id) is ignored'); + } + + #[Test] + public function an_unknown_method_with_an_id_is_a_method_not_found_error(): void + { + $r = $this->handle(['id' => 9, 'method' => 'resources/list']); + $this->assertSame(-32601, $r['error']['code']); + $this->assertSame(9, $r['id']); + } + + #[Test] + public function a_batch_is_refused(): void + { + $r = Tiger_Mcp_Server::handle([['id' => 1, 'method' => 'ping']], 'admin', fn() => null); + $this->assertSame(-32600, $r['error']['code']); + } + + #[Test] + public function tools_call_dispatches_the_named_op_and_wraps_a_success(): void + { + $seen = null; + $dispatch = function ($m, $s, $meth, $args) use (&$seen) { + $seen = [$m, $s, $meth, $args]; + $env = new \stdClass(); + $env->result = 1; $env->data = ['id' => 'x1']; $env->messages = []; + return $env; + }; + $r = $this->handle([ + 'id' => 2, 'method' => 'tools/call', + 'params' => ['name' => 'cms__page__save', 'arguments' => ['title' => 'Hi']], + ], $dispatch); + + $this->assertSame(['cms', 'page', 'save', ['title' => 'Hi']], $seen, 'name split → module/service/method + args'); + $this->assertFalse($r['result']['isError']); + $payload = json_decode($r['result']['content'][0]['text'], true); + $this->assertSame(1, $payload['result']); + $this->assertSame('x1', $payload['data']['id']); + } + + #[Test] + public function tools_call_marks_a_failed_envelope_as_iserror(): void + { + $dispatch = function () { $e = new \stdClass(); $e->result = 0; $e->data = null; $e->messages = []; return $e; }; + $r = $this->handle(['id' => 3, 'method' => 'tools/call', 'params' => ['name' => 'cms__page__delete']], $dispatch); + $this->assertTrue($r['result']['isError'], 'result=0 → isError'); + } + + #[Test] + public function tools_call_rejects_a_malformed_tool_name(): void + { + $called = false; + $r = $this->handle(['id' => 4, 'method' => 'tools/call', 'params' => ['name' => 'not-a-tool']], + function () use (&$called) { $called = true; }); + $this->assertTrue($r['result']['isError']); + $this->assertFalse($called, 'a bad name never reaches dispatch'); + $this->assertStringContainsString('Unknown tool', $r['result']['content'][0]['text']); + } + + #[Test] + public function tool_name_round_trips_and_keeps_method_underscores(): void + { + $this->assertSame('access__user__datatable', Tiger_Mcp_Server::toolName('access', 'user', 'datatable')); + $this->assertSame(['agent', 'skills', 'toggle_active'], Tiger_Mcp_Server::parseToolName('agent__skills__toggle_active')); + $this->assertNull(Tiger_Mcp_Server::parseToolName('onlytwo__parts')); + $this->assertNull(Tiger_Mcp_Server::parseToolName('')); + } +}