Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
mcp-gateway
Streamable-HTTP MCP server that lets an AI
agent work inside one organization of the platform. Owned by the org-platform
workstream; see docs/plan/TODO-agent-1-org-platform.md Phase 5.
pnpm mcp:dev
That runs python -m app.interfaces.mcp.server, which binds
MCP_GATEWAY_HOST:MCP_GATEWAY_PORT (127.0.0.1:8765 by default) and serves
/mcp on the streamable-HTTP transport.
What a client sees
Eight tools, each guarded by a scope the token was minted with:
| tool | scope | what it does |
|---|---|---|
app_get_profile |
app:read |
the org's app profile: type, language, framework, build/test commands, port |
tasks_list |
tasks:read |
the org's tasks, newest first |
tasks_get |
tasks:read |
one task by id |
tasks_create |
tasks:write |
a new task for the org's manager, as a user order |
memory_search |
memory:read |
rules, preferences, lessons, code map |
memory_write |
memory:write |
teach a fact or a preference (nothing else) |
knowledge_search |
memory:read |
the org's long-form knowledge sources |
pipeline_status |
delivery:read |
delivery state — currently always {"available": false} |
Rules the gateway enforces
- The organization comes from the token, never from the arguments. A tool body cannot be pointed at another tenant, whatever it is asked.
- A token is shown once. Mint one in
/settings→ «یکپارچهسازی (MCP)»; the raw value (smo_…) appears in that one response and is never stored — only its hash and a six-character prefix, which is what the audit trail carries. - Scopes are a floor. A
tasks:readtoken cannot create a task. - Every call is audited (
mcp.tool_call, token prefix, tool, ok/error) and rate-limited toMCP_RATE_LIMIT_PER_MINUTE(60) per token, in memory. - Every output is redacted. A secret that reaches the gateway does not leave it.
- Two kill switches. The platform owner can disable the gateway globally
(
/admin/mcp) or for one organization; both are honoured on the next call.
Client configuration
The gateway speaks plain MCP over HTTP with a bearer token:
{
"mcpServers": {
"smart-org": {
"url": "https://mcp.example.com/mcp",
"headers": {
"Authorization": "Bearer smo_PASTE_YOUR_TOKEN"
}
}
}
}
Local, without Caddy:
curl -sS http://127.0.0.1:8765/mcp \
-H "Authorization: Bearer smo_PASTE_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Putting Caddy in front of it
Suggested host: mcp.<root domain> (the platform owner configures Caddy — see
infra/):
mcp.example.com {
reverse_proxy 127.0.0.1:8765
}
Keep the gateway bound to loopback in the normal case: the process is small, it
has no session state of its own, and the platform's TLS termination and rate
limiting belong at the edge. MCP_GATEWAY_HOST=0.0.0.0 is for a container that
must be reached directly, not for a public deployment without a proxy.
Environment
| variable | default | meaning |
|---|---|---|
MCP_GATEWAY_HOST |
127.0.0.1 |
bind address |
MCP_GATEWAY_PORT |
8765 |
bind port |
MCP_GATEWAY_ENABLED |
true |
the owner's global switch |
MCP_TOKEN_TTL_DAYS |
90 |
default token lifetime |
MCP_RATE_LIMIT_PER_MINUTE |
60 |
calls per token per minute, in memory |
Tests
pnpm --filter @agy/api test tests/test_mcp.py
They drive McpGateway.call directly (the SDK is a thin adapter over it) and
cover tenant isolation, scope enforcement, revoked/expired tokens, the rate
limit, audit rows, and secret redaction.