Skip to content

MCP — both directions

Model Context Protocol is how a Telbox agent reaches the world, in both directions. Outward-in: every published agent is callable as its own OAuth-gated MCP server at POST /v1/agents/{agent_id}/mcp, so a host like Claude Code, Cursor, Zed, or Codex can bind a narrow, agent-scoped tool surface. Inward-out: an agent can consume external MCP servers (GitHub, Linear, …) through the broker, which SSRF-pins the URL, gates every tool against a Telbox scope, enforces a no-train flag, and lets the owner revoke per connection. Both surfaces ship dark behind apikey_auth_enabled — every route returns 404 developer_platform_disabled until an operator flips the flag.

Dark gating

The entire agent-MCP stack 404s while apikey_auth_enabled is off — the same flag that gates the global /mcp server and the REST API-key path. Calls below assume the flag is on.


Inbound — your agent as an MCP server

POST /v1/agents/{agent_id}/mcp is JSON-RPC 2.0 over the MCP Streamable-HTTP transport — the per-agent analog of the global /mcp server. It speaks initialize, tools/list, and tools/call.

  • Auth reuses the global MCP gate (mcp.auth.authenticate): an OAuth-minted tb_live_* token in Authorization: Bearer …. The OAuth binding, AI-processing consent, and dark-gate all apply unchanged.
  • Authorization: a PRIVATE agent is never MCP-exposed; a WORKSPACE agent requires the caller's OAuth workspace to match the agent's workspace; a revoked agent is refused. Every refusal is a 404 (no existence oracle).

The exposed-tool subset

The agent exposes only the MCP tools listed in agent_definitions.settings['mcp_exposed_tools'] — a subset of the six global MCP tools (send_message, send_voice_note, read_thread, summarize_thread, extract_intent, subscribe_to_thread). When unset or empty it defaults to the read-only pair {read_thread, summarize_thread}, so a freshly-published agent never silently exposes a write tool. Calling a tool the agent didn't expose returns 404 with error_code: agent_tool_not_exposed.

The per-tool scope checks still fire on top, so the effective surface is agent_exposed ∩ caller_scopes.

Send tools are agent-authored

On the per-agent endpoint, send_message / send_voice_note are signed with the agent's own key (recipients verify "from Agent X") and additionally gated by the agent's thread_replies authority — it must resolve to AUTO. A draft-only or ask-first agent is refused (authority_requires_approval) even when the caller holds messages:write.

Point a host at it

Configure the MCP server URL as https://api.telbox.ai/v1/agents/<agent_id>/mcp with Authorization: Bearer <tb_live_…>. The host's tools/list returns exactly the agent's exposed subset.

Call a tool

The SDKs ship a one-line helper that shapes the tools/call JSON-RPC envelope for you.

curl -sS -X POST https://api.telbox.ai/v1/agents/$AGENT_ID/mcp \
  -H "Authorization: Bearer $TB_LIVE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": {
      "name": "read_thread",
      "arguments": { "thread_id": "…" }
    }
  }'
from telbox import TelboxClient

tb = TelboxClient(api_key="tb_live_…")

result = tb.agent_mcp_call(
    agent_id,
    "read_thread",
    {"thread_id": "…"},
)
import { TelboxClient } from "@telbox/sdk";

const tb = new TelboxClient({ apiKey: "tb_live_…" });

const result = await tb.agentMcpCall(
  agentId,
  "read_thread",
  { thread_id: "…" },
);

Discover before you call

Send {"jsonrpc":"2.0","id":1,"method":"tools/list"} to the same endpoint to see the agent's exposed subset before issuing tools/call. initialize returns the server descriptor (serverInfo.name = Telbox Agent · <name>, serverInfo.agentId).


Outbound — consuming an external MCP server via the broker

The broker lets an agent reach an external MCP server safely. Management is first-party (the agent owner, via a JWT) and lives under /v1/agents/{agent_id}/mcp-connections.

Method Path Purpose
GET /v1/agents/{agent_id}/mcp-connections List connections (secrets never returned)
POST /v1/agents/{agent_id}/mcp-connections Register an external server (SSRF-guarded)
DELETE /v1/agents/{agent_id}/mcp-connections/{connection_id} Revoke (tombstone)
POST /v1/agents/{agent_id}/mcp-connections/{connection_id}/call Guarded tool call

On register the broker: runs assert_safe_url (the egress allowlist — blocks metadata IPs / RFC1918 / loopback / CGNAT, https-only in prod); performs a live tools/list against the URL (unless you supply exposed_tools); validates the scope map fail-loud; enforces the no-train consent gate; then KMS-wraps the bearer token (never stored plaintext) and persists.

  • Scope-map (scope_map: {external_tool: telbox_scope}) is an explicit 1:1 map. Every value must be a real Telbox scope and every key must name a tool the server actually exposed, or register fails (unknown_scope / unknown_tool). An unmapped tool is a hard deny at call time — never default-allow.
  • No-train consent (no_train, training_consented): if the endpoint may train on forwarded content (no_train=false) you must set training_consented=true to acknowledge, otherwise register is refused (training_consent_required).
  • namespace must be alphanumeric (with -/_) and is unique per agent (namespace_taken409).
curl -sS -X POST https://api.telbox.ai/v1/agents/$AGENT_ID/mcp-connections \
  -H "Authorization: Bearer $TB_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "namespace": "github",
    "display_name": "GitHub MCP",
    "url": "https://mcp.example.com/github",
    "auth_token": "ghp_…",
    "scope_map": { "list_issues": "threads:read" },
    "no_train": true,
    "training_consented": false
  }'
from telbox import TelboxClient

tb = TelboxClient(api_key="tb_…")

# The broker is REST-only (no dedicated SDK helper yet); use the raw request.
conn = tb._request("POST", f"/v1/agents/{agent_id}/mcp-connections", json={
    "namespace": "github",
    "display_name": "GitHub MCP",
    "url": "https://mcp.example.com/github",
    "auth_token": "ghp_…",
    "scope_map": {"list_issues": "threads:read"},
    "no_train": True,
    "training_consented": False,
})
import { TelboxClient } from "@telbox/sdk";

const tb = new TelboxClient({ apiKey: "tb_…" });

// The broker is REST-only (no dedicated SDK helper yet); use the raw request.
const conn = await tb.request("POST", `/v1/agents/${agentId}/mcp-connections`, {
  namespace: "github",
  display_name: "GitHub MCP",
  url: "https://mcp.example.com/github",
  auth_token: "ghp_…",
  scope_map: { list_issues: "threads:read" },
  no_train: true,
  training_consented: false,
});

The response is a secret-free ConnectionView — it surfaces has_auth: bool and status but never the token, exposed_tools, scope_map, no_train, training_consented, and timestamps.

Call a tool — the hard gates

POST …/mcp-connections/{connection_id}/call forwards one tools/call to the external server. Body shape: {"tool": "...", "arguments": {...}}. Every call passes these gates before a byte leaves the host:

  1. Connection live — exists, owned, enabled, not revoked.
  2. Scope-map authorization — an unmapped tool is a hard deny (tool_not_authorized).
  3. Caller scope — a scoped caller must hold the mapped Telbox scope (a first-party JWT caller is unrestricted, but the scope map still gates which tools are callable).
  4. No-train — a training endpoint without consent is refused (training_consent_required), re-checked here as defence in depth.
  5. Rate cap — a per-(agent, connection) token bucket (burst 30, 600/hour) so one external server can't starve another.
  6. Egress audit — a platform_audit agent.mcp_broker.egress row is written before the forward (tool + url + no-train flag; never the token).
  7. SSRF-pinned forward — the URL is re-validated and DNS-pinned at call time (anti-rebinding), the original hostname is preserved for TLS SNI, and redirects are not followed.
curl -sS -X POST \
  https://api.telbox.ai/v1/agents/$AGENT_ID/mcp-connections/$CONNECTION_ID/call \
  -H "Authorization: Bearer $TB_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "tool": "list_issues",
    "arguments": { "repo": "telbox/telbox", "state": "open" }
  }'
out = tb._request(
    "POST",
    f"/v1/agents/{agent_id}/mcp-connections/{connection_id}/call",
    json={"tool": "list_issues",
          "arguments": {"repo": "telbox/telbox", "state": "open"}},
)
# out -> {"result": <jsonrpc-result>, "error": <jsonrpc-error-or-null>}
const out = await tb.request(
  "POST",
  `/v1/agents/${agentId}/mcp-connections/${connectionId}/call`,
  { tool: "list_issues", arguments: { repo: "telbox/telbox", state: "open" } },
);
// out -> { result: <jsonrpc-result>, error: <jsonrpc-error-or-null> }

The response passes the external server's JSON-RPC envelope through as {"result": …, "error": …} — a JSON-RPC error member is the external server's business and is returned, not raised.

Revoke — per connection

Revocation is a tombstone (status=revoked, revoked_at, enabled=false) and is idempotent. Every subsequent call is refused (connection_revoked403).

curl -sS -X DELETE \
  https://api.telbox.ai/v1/agents/$AGENT_ID/mcp-connections/$CONNECTION_ID \
  -H "Authorization: Bearer $TB_JWT"
# 204 No Content
tb._request("DELETE", f"/v1/agents/{agent_id}/mcp-connections/{connection_id}")
await tb.request("DELETE", `/v1/agents/${agentId}/mcp-connections/${connectionId}`);

See also

  • Authentication — OAuth tb_live_* tokens (inbound) and first-party JWT (outbound management).
  • AI and privacy — the no-train posture the broker enforces per connection.
  • Errorserror_code shapes for refusals.
  • Rate limits — the per-connection and per-developer budgets.