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-mintedtb_live_*token inAuthorization: Bearer …. The OAuth binding, AI-processing consent, and dark-gate all apply unchanged. - Authorization: a
PRIVATEagent is never MCP-exposed; aWORKSPACEagent requires the caller's OAuth workspace to match the agent's workspace; a revoked agent is refused. Every refusal is a404(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.
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 |
Register — SSRF-pinned URL, scope-map, no-train consent¶
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 settraining_consented=trueto acknowledge, otherwise register is refused (training_consent_required). namespacemust be alphanumeric (with-/_) and is unique per agent (namespace_taken→409).
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:
- Connection live — exists, owned, enabled, not revoked.
- Scope-map authorization — an unmapped tool is a hard deny (
tool_not_authorized). - 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).
- No-train — a training endpoint without consent is refused (
training_consent_required), re-checked here as defence in depth. - Rate cap — a per-
(agent, connection)token bucket (burst 30, 600/hour) so one external server can't starve another. - Egress audit — a
platform_auditagent.mcp_broker.egressrow is written before the forward (tool + url + no-train flag; never the token). - 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.
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_revoked → 403).
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.
- Errors —
error_codeshapes for refusals. - Rate limits — the per-connection and per-developer budgets.