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
2 changes: 1 addition & 1 deletion CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
25 changes: 15 additions & 10 deletions TIGERMCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.

---

Expand Down Expand Up @@ -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
Expand Down
137 changes: 137 additions & 0 deletions bin/mcp-bridge.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#!/usr/bin/env php
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* mcp-bridge.php — a zero-dependency stdio <-> 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']));
}
24 changes: 19 additions & 5 deletions modules/mcp/Bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);
}
}
12 changes: 11 additions & 1 deletion modules/mcp/configs/acl.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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]
49 changes: 49 additions & 0 deletions modules/mcp/controllers/AdminController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Mcp_AdminController — the MCP **Connect** screen (admin shell, /mcp/admin; TIGERMCP.md §6). Turn the
* server on/off, mint an access token, and copy a ready-to-paste MCP client config. Thin: the enable
* toggle is Mcp_Service_Settings, tokens are the core Tiger_Service_Token, both over /api. `download`
* serves the zero-Node stdio bridge so a user can drop it on their machine.
*/
class Mcp_AdminController extends Tiger_Controller_Admin_Action
{
public function init()
{
parent::init();
}

/** The Connect screen: current state + the values the config blocks are built from. */
public function indexAction()
{
$this->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();
}
}
10 changes: 10 additions & 0 deletions modules/mcp/languages/en/mcp.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* TigerMCP — English strings. Semantic, owner-prefixed keys (AGENTS.md i18n).
*/
return [
'mcp.settings.enabled' => 'MCP server enabled.',
'mcp.settings.disabled' => 'MCP server disabled.',
];
32 changes: 32 additions & 0 deletions modules/mcp/services/Settings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Mcp_Service_Settings — the /api behind the MCP Connect screen: turn the `/mcp` endpoint on/off. Thin +
* admin-gated; the value lives in the `config` live-override tier (effective next request, no deploy).
* Token minting reuses the core Tiger_Service_Token (module=tiger, service=token); this service owns only
* the enable flag.
*
* @api
*/
class Mcp_Service_Settings extends Tiger_Service_Service
{
/**
* Enable or disable the MCP server (`tiger.mcp.enabled`).
*
* @param array $params {enabled: bool-ish}
* @return void
*/
public function save(array $params): void
{
if (!$this->_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');
}
}
}
Loading
Loading