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(''));
+ }
+}