Files
smart-org/services/mcp-gateway

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:read token cannot create a task.
  • Every call is audited (mcp.tool_call, token prefix, tool, ok/error) and rate-limited to MCP_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.