Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down Expand Up @@ -198,6 +198,11 @@
- **Tiger_Skill_Source_SkillsDir** `@api` — the adapter for the common "collection" layout: a repo whose skills live as `<base>/<name>/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`
Expand Down Expand Up @@ -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`
Expand Down
21 changes: 14 additions & 7 deletions TIGERMCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.

---

Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions bin/build-capabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']],
Expand Down
57 changes: 57 additions & 0 deletions library/Tiger/Mcp.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Tiger_Mcp — the facade for the MCP server (Tiger as an MCP server; see TIGERMCP.md).
*
* MCP lets an external AI client (Claude Desktop/Code, Cursor, ChatGPT) drive a Tiger install through the
* SAME token-authenticated, ACL-gated `/api` surface the browser + in-app agent use — reach, not capability
* (`Tiger_Mcp_Server` is the JSON-RPC engine; the `mcp` module's controller is the thin HTTP surface). This
* facade holds the two cross-cutting bits: the OFF-by-default enable gate and protocol-version negotiation.
*
* @api
* @see Tiger_Mcp_Server the JSON-RPC protocol engine
*/
class Tiger_Mcp
{
/** The MCP protocol version this server speaks by default (date-versioned, per the spec). */
const PROTOCOL_VERSION = '2025-06-18';

/** Versions we can negotiate — echo the client's back if it's one of these. */
const SUPPORTED_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];

/** Config key — the endpoint is OFF unless an admin sets this (a shared-host install must opt in). */
const CONFIG_ENABLED = 'tiger.mcp.enabled';

/**
* Is the `/mcp` endpoint enabled? Off by default — `/mcp` 404s until `tiger.mcp.enabled` is truthy.
*
* @return bool
*/
public static function isEnabled()
{
try {
if (!Zend_Registry::isRegistered('Zend_Config')) { return false; }
$cfg = Zend_Registry::get('Zend_Config');
$mcp = ($cfg->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;
}
}
151 changes: 151 additions & 0 deletions library/Tiger/Mcp/Server.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Tiger_Mcp_Server — the MCP JSON-RPC 2.0 protocol engine (transport-agnostic; TIGERMCP.md §3-5).
*
* Handles one decoded MCP message and returns the JSON-RPC response (or null for a notification). It owns the
* `initialize` / `tools/list` / `tools/call` / `ping` methods and nothing else — the `mcp` module's controller
* is the HTTP surface (reads the body, resolves the Bearer identity, echoes the result), and the `$dispatch`
* callback is the seam that runs a `tools/call` through `/api` as the caller. This split keeps the protocol
* logic pure + unit-testable with no HTTP, DB, or network.
*
* The tool surface IS the role-filtered `/api` catalog (`Tiger_Agent_Tools::catalog`) — MCP adds reach, not
* capability; every `tools/call` is gated by the target service's own ACL via the dispatch seam.
*
* @api
* @see Tiger_Mcp the enable gate + version negotiation
* @see Tiger_Agent_Tools the role-filtered /api catalog reflected into tools/list
*/
class Tiger_Mcp_Server
{
/**
* Handle one decoded JSON-RPC 2.0 message.
*
* @param array $msg the decoded request: {jsonrpc, id?, method, params?}
* @param string $role the caller's role (filters tools/list)
* @param callable $dispatch fn(string $module, string $service, string $method, array $args): object
* — runs the named /api op and returns its response envelope
* @return array|null the JSON-RPC response, or null for a notification (no id → send no body)
*/
public static function handle(array $msg, $role, callable $dispatch)
{
// A JSON array = a batch, which MCP removed in 2025-06-18. Refuse it.
if (array_is_list($msg)) {
return self::_error(null, -32600, 'Batch requests are not supported');
}

$id = $msg['id'] ?? null;
$method = (string) ($msg['method'] ?? '');
$params = (isset($msg['params']) && is_array($msg['params'])) ? $msg['params'] : [];

switch ($method) {
case 'initialize':
return self::_result($id, self::_initialize($params));
case 'notifications/initialized':
case 'notifications/cancelled':
return null; // notifications never get a response
case 'ping':
return self::_result($id, new stdClass()); // {}
case 'tools/list':
return self::_result($id, self::_toolsList((string) $role));
case 'tools/call':
return self::_result($id, self::_toolsCall($params, $dispatch));
default:
if ($id === null) { return null; } // an unknown NOTIFICATION → ignore silently
return self::_error($id, -32601, 'Method not found: ' . $method);
}
}

/** The `initialize` result — advertise the tools capability + serverInfo. */
protected static function _initialize(array $params)
{
return [
'protocolVersion' => 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 "<module>__<service>__<method>"; 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>__<method>` (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]];
}
}
18 changes: 18 additions & 0 deletions modules/mcp/Bootstrap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* 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).
*
* 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
{
}
16 changes: 16 additions & 0 deletions modules/mcp/configs/acl.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
; SPDX-License-Identifier: BSD-3-Clause
; Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
;
; /mcp is PUBLIC at the controller level, exactly like ApiController: the MCP server does its own Bearer-token
; auth, and every tools/call is dispatched through /api where the TARGET service's own ACL gates it
; (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.rules.pub_mcp.role = "guest"
acl.rules.pub_mcp.resource = "Mcp_ServerController"
acl.rules.pub_mcp.permission = "allow"
[staging : production]
[testing : production]
[development : production]
15 changes: 15 additions & 0 deletions modules/mcp/configs/routes.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
; SPDX-License-Identifier: BSD-3-Clause
; Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
;
; The single MCP endpoint: POST /mcp -> 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]
Loading
Loading