diff --git a/TIGERMCP.md b/TIGERMCP.md index e0090f8..f9f0253 100644 --- a/TIGERMCP.md +++ b/TIGERMCP.md @@ -8,13 +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: increments 1 + 3 BUILT; 2 + 4 scoped.** The `modules/mcp` core module ships the JSON-RPC +> **Status: increments 1 + 2 + 3 BUILT; 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 +> bridge, bridge download). Tool arguments are **typed** (increment 2 — `tools/list` `inputSchema` comes +> from each method's `@apiRequest` Form via the OpenAPI generator's mapper). **OFF by default** +> (`tiger.mcp.enabled`). Still scoped, not built: 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**. @@ -242,8 +243,12 @@ MCP** IA (TIGERSKILLS §6): `MCP ▸ Server/Access` (inbound, this doc) and `MCP `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). +2. **Tool `inputSchema` — ✅ BUILT.** `Tiger_OpenApi_Generator::schemasByOp()` exposes the Form→JSON-Schema + mapping keyed by `module/service/method`; `Tiger_Mcp_Server::_toolsList` reflects it into each tool's + `inputSchema` (fault-tolerant — a permissive object if reflection fails). A method types its arguments by + declaring `@apiRequest `; the curated create/edit tools now carry it (`Cms_Form_Page`, + `Blog_Form_Post`, `Access_Form_User`, `Access_Form_Org`). **Follow-on:** backfill `@apiRequest` across the + rest of the writable services (benefits `/api/openapi` too — same mechanism). 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/ diff --git a/library/Tiger/Mcp/Server.php b/library/Tiger/Mcp/Server.php index 701dc9c..6f3f228 100644 --- a/library/Tiger/Mcp/Server.php +++ b/library/Tiger/Mcp/Server.php @@ -71,22 +71,41 @@ protected static function _initialize(array $params) ]; } - /** tools/list = the role-filtered /api catalog, one MCP tool per operation. */ + /** tools/list = the role-filtered /api catalog, one MCP tool per operation, args typed from the Form. */ protected static function _toolsList($role) { - $tools = []; + $schemas = self::_inputSchemas(); // module/service/method → JSON Schema (from the method's Form) + $tools = []; foreach (Tiger_Agent_Tools::catalog($role) as $module => $ops) { foreach ($ops as $op) { + $key = $module . '/' . $op['service'] . '/' . $op['method']; $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 + 'inputSchema' => $schemas[$key] ?? ['type' => 'object'], // typed from the Form, else permissive ]; } } return ['tools' => $tools]; } + /** + * The Form-derived input schemas keyed by `//`, reflected once from the OpenAPI + * generator (which maps each method's `@apiRequest` Form → a JSON Schema). Fault-tolerant: a schema is a + * nicety, so any failure falls back to a permissive object per tool. + * + * @return array + */ + protected static function _inputSchemas() + { + try { + $gen = new Tiger_OpenApi_Generator(); + return $gen->schemasByOp($gen->discover($gen->moduleServiceDirs())); + } catch (Throwable $e) { + return []; + } + } + /** tools/call → dispatch the named /api op through the seam, wrap the envelope as MCP content. */ protected static function _toolsCall(array $params, callable $dispatch) { diff --git a/library/Tiger/OpenApi/Generator.php b/library/Tiger/OpenApi/Generator.php index 5f90dfb..4afbb11 100644 --- a/library/Tiger/OpenApi/Generator.php +++ b/library/Tiger/OpenApi/Generator.php @@ -112,6 +112,33 @@ public function generate(array $serviceClasses) ]; } + /** + * Request (input) JSON Schemas keyed by `//` — the same Form-derived schemas + * `generate()` puts in each operation's `requestBody`, extracted for reuse (e.g. MCP `tools/list` + * `inputSchema`). A method with an `@apiRequest ` gets a typed object schema; a form-less + * method gets a generic permissive object. See TIGERMCP.md §4. + * + * @param array $serviceClasses the discovered @api service class names + * @return array op key => JSON Schema + */ + public function schemasByOp(array $serviceClasses) + { + $out = []; + foreach ($serviceClasses as $class) { + if (!$this->_isService($class)) { + continue; + } + [$module, $service] = $this->_moduleService($class); + foreach ($this->_operations($class) as $method) { + $schema = $this->_requestSchema($this->_docblock($method)); + if ($schema !== null) { + $out[$module . '/' . $service . '/' . $method->getName()] = $schema; + } + } + } + return $out; + } + // ============================================================================= operations /** One OpenAPI operation from a reflected service method. */ diff --git a/modules/access/services/Org.php b/modules/access/services/Org.php index 633835e..512d104 100644 --- a/modules/access/services/Org.php +++ b/modules/access/services/Org.php @@ -67,6 +67,7 @@ public function datatable(array $params): void * Create or update an org (insert when org_id is empty). * * @param array $params the submitted form values + org_id + * @apiRequest Access_Form_Org * @return void */ public function save(array $params): void diff --git a/modules/access/services/User.php b/modules/access/services/User.php index 4c3fe1e..b42bc98 100644 --- a/modules/access/services/User.php +++ b/modules/access/services/User.php @@ -64,6 +64,7 @@ public function datatable(array $params): void * Create or update a user identity (insert when user_id is empty). * * @param array $params the submitted form values + user_id + * @apiRequest Access_Form_User * @return void */ public function save(array $params): void diff --git a/modules/blog/services/Post.php b/modules/blog/services/Post.php index 9ec2d44..4843322 100644 --- a/modules/blog/services/Post.php +++ b/modules/blog/services/Post.php @@ -64,6 +64,7 @@ public function datatable(array $params): void * Create or update an article (insert when post_id is empty). * * @param array $params the submitted form values + post_id + * @apiRequest Blog_Form_Post * @return void */ public function save(array $params): void diff --git a/modules/cms/services/Page.php b/modules/cms/services/Page.php index 6150864..d5345d7 100644 --- a/modules/cms/services/Page.php +++ b/modules/cms/services/Page.php @@ -259,6 +259,7 @@ public function forkTheme(array $params): void * Create or update a page (insert when page_id is empty). * * @param array $params the editor form payload + * @apiRequest Cms_Form_Page * @return void */ public function save(array $params): void diff --git a/tests/Integration/Mcp/McpControllerTest.php b/tests/Integration/Mcp/McpControllerTest.php index 314b8bd..3fa03f3 100644 --- a/tests/Integration/Mcp/McpControllerTest.php +++ b/tests/Integration/Mcp/McpControllerTest.php @@ -89,6 +89,14 @@ public function tools_list_reflects_the_role_catalog_when_enabled(): void $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]); + + // A method that declares @apiRequest gets a TYPED inputSchema from its Form (increment 2). + $byName = array_column($out['result']['tools'], null, 'name'); + $this->assertArrayHasKey('cms__page__save', $byName, 'the page-save tool is exposed to an admin'); + $schema = $byName['cms__page__save']['inputSchema']; + $this->assertSame('object', $schema['type']); + $this->assertArrayHasKey('title', $schema['properties'], 'the Cms_Form_Page fields are typed into the schema'); + $this->assertArrayHasKey('slug', $schema['properties']); } }