Reference
Agent protocol
The wire protocol between an agent and the gateway, for connecting without an SDK or building one for a language we do not cover yet.
An SDK is a thin layer over a small HTTP protocol: three endpoints, one long-lived stream, and a handful of JSON messages. This page is the whole protocol. Read it if you want to connect without an SDK, if you are building one for another language, or if you want to know exactly what an SDK does for you. Everything here was checked against a live gateway, and a complete worked session is at the end.
If you just want to expose functions, use an SDK instead: Node.js or Go.
Overview
There is one stream per agent, opened by the agent. Everything the gateway wants the agent to do arrives as an event on that stream. Everything the agent wants to tell the gateway goes in a separate, ordinary HTTPS request that quotes the connectionId the gateway handed out in the first event.
| Direction | Mechanism | Purpose |
|---|---|---|
| Agent to gateway | POST /agent/connect, response kept open | Register tools, resources and prompts, and receive events |
| Gateway to agent | Server-sent events on that response | ready, call, read, prompt, and : ping comments |
| Agent to gateway | POST /agent/result | Answer one call |
| Agent to gateway | POST /agent/telemetry | Report process metrics (optional) |
Conventions
- Base URL. The gateway's origin, for example
https://api.spacefix.io. Append the three paths to it. - Bodies. Every
POSTto/agent/*must carryContent-Type: application/json, otherwise the gateway answers415{"error":"Content-Type must be application/json."}. Error responses are JSON of the form{"error": "<message>"}. - Transport. HTTPS. Any proxy between you and the gateway must pass server-sent events through unbuffered and must not close an idle response sooner than the 25-second ping.
- Names. JSON field names are exactly as written here, in camelCase.
- Forward compatibility. Ignore event types and JSON fields you do not know.
- Clocks.
sentAtis the gateway's clock andinboundMsis computed from yours. They are only comparable to within clock skew.
Authentication
Every agent endpoint takes the same header:
Authorization: Bearer <key>
The key is a project agent key or a full key. A client key is refused. The key identifies the project, so there is no project id in the agent URLs.
| Situation | Status | Body | Headers |
|---|---|---|---|
No Authorization header | 401 | {"error":"Missing API key."} | WWW-Authenticate: Bearer realm="mcp" |
| Key unknown, revoked or expired | 401 | {"error":"Invalid API key."} | WWW-Authenticate: Bearer realm="mcp", error="invalid_token", error_description="Invalid API key." |
| A client key was used | 403 | {"error":"This is a client key. Agents connect with an agent key."} | none |
| Too many rejected keys from this address (30 a minute) | 429 | {"error":"Too many failed authentication attempts."} | Retry-After: <seconds> |
Only a key that was offered and rejected counts toward the failed-key budget. A missing header and a valid key never do. Send Bearer exactly as shown, with a capital B and a space.
Revoking a key closes the streams that were opened with it. A key that expires also closes them, and the reconnect then gets 401.
POST /agent/connect
Registers what this agent exposes and opens the event stream. Call it once per connection. There is no way to change the registration of a live connection: to change it, drop the stream and connect again.
Request
POST /agent/connect HTTP/1.1
Authorization: Bearer <agent or full key>
Content-Type: application/json
{
"tools": [
{
"name": "get_order",
"description": "Look up a single order by its id.",
"inputSchema": {
"type": "object",
"properties": { "id": { "type": "string" } },
"required": ["id"]
}
}
],
"resources": [
{ "uri": "orders://all", "name": "All orders", "description": "Every order", "mimeType": "application/json" }
],
"prompts": [
{
"name": "summarise_order",
"title": "Summarise order",
"description": "Summarise an order.",
"arguments": [{ "name": "id", "description": "Order id", "required": true }]
}
]
}
| Field | Type | Required | Meaning |
|---|---|---|---|
tools | array | Yes (may be empty) | Tools this agent serves |
resources | array | No, default [] | Resources this agent serves |
prompts | array | No, default [] | Prompts this agent serves |
Tool (tools[])
| Field | Type | Required | Rules |
|---|---|---|---|
name | string | Yes | Letters, digits, _ and -, 1 to 64 characters. If two tools share a name, the later one replaces the earlier |
description | string | No | Shown to MCP clients and to the model, so write it for the model |
inputSchema | object | No | Plain JSON Schema, at most 65,536 bytes as text on the Free plan. It is compiled when you connect, so an invalid schema is a 400 here and not an error on every call. When absent the tool takes {"type":"object"} |
annotations | object | No | Hints for clients. Only these keys are kept: title (string, at most 200 characters) and the booleans readOnlyHint, destructiveHint, idempotentHint, openWorldHint. Anything else is dropped; a wrong type is a 400 naming the tool (for example Annotation "readOnlyHint" for "get_order" must be true or false.). The gateway also uses readOnlyHint: only tools with readOnlyHint: true (or that the owner marks Read) are offered to read-only clients; a missing or false value counts as a write |
Resource (resources[])
| Field | Type | Rules |
|---|---|---|
uri | string | Required, non-empty, at most 2,048 characters, unique within the registration. The read event's name is this uri |
name | string | Send one: it is the display name in resource listings. Must be a string if present |
description | string | Optional |
mimeType | string | Optional. Clients are told application/json when absent |
At most 1,000 resources.
Prompt (prompts[])
| Field | Type | Rules |
|---|---|---|
name | string | The same rule as a tool name |
title | string | Optional. Shown in client menus |
description | string | Optional |
arguments | array | Optional. Each { name, description?, required? }. Must be an array if present. Every prompt argument is a string |
What the gateway checks, in order
It stops at the first failure. Everything before the last step is an ordinary JSON error response, because the stream has not started.
- Authentication (see above).
- The project is not suspended, or
403with a JSON-RPC-shaped body. - The connect rate limit: 60 attempts a minute per project on Free. A failed attempt still spends a token.
Content-Type: application/json, or415.- The body parses and is at most 512 KB (
413otherwise). toolsis an array, thenresources, thenprompts.- Each prompt has a valid name and array
arguments. - Each resource is valid (see below).
- At most 256 tools on Free, then each tool's name, schema size and schema validity.
- The project has fewer than 32 live connections on Free.
- Accepted: the gateway writes
200and the stream headers, then thereadyevent.
Errors on connect
| Status | Body (error) | Cause |
|---|---|---|
400 | "tools" must be an array. | Missing or non-array tools |
400 | "resources" must be an array. / "prompts" must be an array. | Wrong type |
400 | Invalid prompt "<name>". | The name fails the rule, or arguments is not an array |
400 | Invalid resource <uri>. | A resource without a usable uri (missing, empty, over 2,048 characters, or not an object) |
400 | Invalid name for resource "<uri>". | name is present but not a string |
400 | Duplicate resource "<uri>". | Two resources share a uri |
400 | Too many resources: limit is 1000. | More than 1,000 resources |
400 | Too many tools: limit is <n>. | More tools than the plan allows |
400 | Invalid tool name "<name>". | The name fails the rule |
400 | Schema for "<name>" exceeds <n> bytes. | inputSchema too large |
400 | Schema for "<name>" is not valid JSON Schema: <detail> | The schema failed to compile |
400 | The JSON parser's message | The body is not valid JSON |
415 | Content-Type must be application/json. | Missing or wrong Content-Type |
413 | request entity too large | Registration over 512 KB |
429 | Rate limit exceeded: <n> requests per minute. | Connect rate limit, with Retry-After |
429 | Too many agent connections: the <Plan> plan allows <n>. | Connection cap reached, no Retry-After |
403 | {"jsonrpc":"2.0","id":null,"error":{"code":-32003,"message":"This project is suspended: <reason>"}} | The project or account is suspended |
503 | Service is shutting down. | The gateway is restarting |
The 200 response
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no
The body never ends while the agent is healthy. Read it incrementally: a client that buffers the whole response (a default curl without -N, for example) sees nothing. Your HTTP client needs no overall request timeout on this call, only a connect timeout, and no read timeout shorter than the 25-second ping.
The event stream
Frames
Frames are standard server-sent events, always produced in exactly this form:
event: <name>
data: <one line of JSON>
event:anddata:are each one line, anddatanever contains a newline.- A frame ends with a blank line. Frames can be split across network reads at any byte, so buffer until you see a blank line.
- A line starting with
:is a comment. The gateway sends: pingand a blank line every 25 seconds so proxies do not close an idle stream. Ignore them. They are your only liveness signal. - There is no
id:orretry:field and noLast-Event-ID. A reconnect is always a fresh registration and a fresh connection. - Be liberal in what you parse: accept
\r\n, an optional space after the colon, and severaldata:lines joined with\n.
Events
| Event | When | Payload |
|---|---|---|
ready | Once, immediately after the 200 | { "connectionId": "<uuid>" } |
call | A client called a tool | { id, name, args, trace } |
read | A client read a resource | { id, name, trace } |
prompt | A client asked for a prompt | { id, name, args, trace } |
(comment) : ping | Every 25 seconds | none |
ready means connected. It is the moment to log success and reset your reconnect backoff. No call arrives before it.
event: ready
data: {"connectionId":"930b2ffd-173f-4dbd-b5f6-37d5e7dbf401"}
connectionId is the handle for this stream. Quote it in every /agent/result and /agent/telemetry request, and treat it like a credential.
call, read, prompt
event: call
data: {"id":"2c3722a4-a76d-4269-8c35-0202b08f1d64","name":"get_order","args":{"id":"1"},"trace":{"traceparent":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01","tracestate":"vendor=demo","requestId":"af76b887-9fb2-40fd-aa25-1c237afbcab5","sentAt":1793411561765}}
| Field | Type | Present on | Meaning |
|---|---|---|---|
id | string | all three | The call id. Quote it in /agent/result |
name | string | all three | For call, the tool name. For read, the resource uri. For prompt, the prompt name |
args | JSON | call, prompt | The arguments. For a tool, an object the gateway has already checked against your inputSchema. The keywords pattern, patternProperties and format are listed to clients but never enforced, so enforce them yourself if they matter. For a prompt, an object of strings. Absent on read: treat a missing args as {} |
trace | object | all three, when there is trace context | Absent when the call came from the dashboard's Playground |
trace has these fields:
| Field | Type | Meaning |
|---|---|---|
traceparent | string | A W3C traceparent. When the client sent a valid one it is passed on unchanged. Otherwise the gateway made one, so every call can be followed |
tracestate | string, optional | The client's tracestate, cut to 512 characters, present only when the client sent the traceparent |
requestId | string | The id shown as Request id on the Logs page. Put it on your spans as spacefix.request_id |
sentAt | number | The gateway's clock when the event was written, in milliseconds since the Unix epoch. now - sentAt on your side is the gateway-to-agent latency, plus clock skew |
Ordering and concurrency
Events are written in the order they are dispatched, and nothing waits for the previous call to finish. The gateway may write many events back to back, and you must run them concurrently, without blocking your stream reader. Answers can be posted in any order, because they are matched by id. There is no backpressure signal and no limit on outstanding calls on one stream: your agent decides how many handlers to run at once and is responsible for protecting itself.
POST /agent/result
Answers one call, read or prompt event.
POST /agent/result HTTP/1.1
Authorization: Bearer <agent or full key>
Content-Type: application/json
{
"connectionId": "930b2ffd-173f-4dbd-b5f6-37d5e7dbf401",
"id": "2c3722a4-a76d-4269-8c35-0202b08f1d64",
"ok": true,
"result": { "id": "1", "item": "Widget", "total": 4200 },
"span": { "handlerMs": 3, "inboundMs": 2, "spanId": "b7ad6b7169203331" }
}
| Field | Type | Required | Meaning |
|---|---|---|---|
connectionId | string | Yes | From ready |
id | string | Yes | The event's id |
ok | boolean | Yes | true for success. Anything else, including missing, counts as failure |
result | JSON | When ok is true | The value to return. May be a string, number, object, array or null. If omitted the caller gets the text null |
error | string | When ok is false | The message returned to the caller. If omitted the gateway uses Tool failed. |
span | object | Recommended | How your agent saw the call. All parts are optional |
span fields. Each is checked, and a part that is out of range is silently dropped. The result is never rejected because of span.
| Field | Type | Valid range | Meaning |
|---|---|---|---|
handlerMs | number | 0 to 3,600,000 | How long your handler took. Shown as Handler time in Logs |
inboundMs | number | 0 to 3,600,000 | How long the call took to reach you (yourNow - sentAt). Omit it when negative |
spanId | string | exactly 16 lowercase hex characters | The id of your OpenTelemetry span for the call, so Traces can show it |
Never put arguments or results in span.
The response
204 No Content, with no body, once authentication succeeds and the body parses. That includes the cases where the gateway ignored the result: the connectionId is unknown (the stream already ended), the id is unknown or already answered or already timed out, or the connectionId belongs to another project. You cannot tell whether a result was used. That is deliberate: it makes retries harmless.
Other responses: 415 for the wrong content type, 400 with the parser's message for malformed JSON, 413 request entity too large when the body is over 4 MB, and 401, 403 and 429 as in Authentication. Results are not rate limited, because throttling them would time out calls the gateway already accepted. A result over 4 MB is refused with 413, so the call is not settled and ends with the timeout. Report ok: false with a short message instead.
How the result reaches the client
| Event | ok: true | ok: false |
|---|---|---|
call | A string becomes one text block. An object with a content array is passed through untouched. Anything else is JSON-encoded into a text block | The tool result has isError: true and one text block containing error |
read | A string is the resource text, anything else is JSON-encoded, with the registered mimeType (default application/json) | The client gets a JSON-RPC error |
prompt | A string becomes one user message. An object with a messages array is passed through. Anything else is JSON-encoded into one user message | The client gets a JSON-RPC error |
Every failure reaches the model as text, so there is no error code in the agent protocol: ok and error are all there is. Make messages actionable (No order 999. Ask the user to confirm the id.), never put secrets in them, and send only the message, never a stack trace.
POST /agent/telemetry
Optional. Reports one sample of process health, shown on the dashboard.
{
"connectionId": "930b2ffd-173f-4dbd-b5f6-37d5e7dbf401",
"metrics": {
"uptime": 42, "rss": 81264640, "heapUsed": 14120000, "heapTotal": 22000000,
"cpu": 0.03, "eventLoopDelay": 10.4, "eventLoopUtilization": 0.012,
"tools": 1, "calls": 4, "errors": 1, "inFlight": 0, "peakInFlight": 3, "avgMs": 12
}
}
| Field | Unit | Meaning |
|---|---|---|
uptime | seconds | Process uptime |
rss | bytes | Resident memory |
heapUsed, heapTotal | bytes | Heap in use and allocated. Send your runtime's closest equivalent |
cpu | 0 to 1 | Share of one core used since the previous sample |
eventLoopDelay | milliseconds | Mean scheduler delay over the window. Send the closest figure, or 0 |
eventLoopUtilization | 0 to 1 | Fraction of the window the scheduler was busy. 0 if not applicable |
tools | count | Tools the agent exposes |
calls, errors | counts | Calls handled, and how many failed, since the previous sample (window counters, not totals) |
inFlight | count | Calls running at the instant of sampling |
peakInFlight | count | The most simultaneous calls in the window |
avgMs | milliseconds | Mean handler duration in the window |
The response is 204 always, once authentication and the content type pass, including when the sample was ignored. A sample is kept only for a live connectionId of your project, and a field that is missing or not a number counts as 0. Send one every 10 seconds, which the dashboard's rate figures assume. See Observability.
Reconnect and backoff
Treat the stream as disposable. A gateway restart, a deploy, a network blip, a revoked or expired key and a proxy cutting the connection all end it, and the only recovery is to connect again.
| Step | What to do |
|---|---|
| Any failure | A non-200 from /agent/connect, a network error, or the stream ending for any reason counts as a failure |
| First delay | 1 second |
| Growth | Double after each consecutive failure: 1, 2, 4, 8, 16, 30, 30, ... seconds. Ceiling 30 seconds |
| Reset | Back to 1 second the moment a ready event arrives |
| Jitter | Add randomness to every delay (for example, wait between half and all of it), so replicas that lost the stream together do not retry in lockstep |
Retry-After | If the response carries one (a 429 does), wait at least that many seconds |
| Stop | Stop retrying when your process shuts down |
The Node.js and Go SDKs do all of this. A project is limited to 60 connection attempts a minute on Free, and a fleet reconnecting in lockstep is how that limit is reached.
Detect a dead stream. The gateway pings every 25 seconds. If no bytes at all (frames or pings) arrive for several intervals, say 75 seconds, abort and reconnect. A half-open connection otherwise looks healthy for a long time.
Retrying a 4xx. A 401 or 403 may be fixed later (the key can be replaced), so keep retrying slowly rather than giving up. A 400 will fail the same way until you change the registration.
What the gateway does when a stream ends
The gateway removes the connection and fails every call still waiting on it with a message that reaches the client as a tool error:
| Message | Cause |
|---|---|
Agent disconnected before returning a result. | The stream closed from your side: process exit, network loss, a proxy |
Key revoked. | The key this agent used was revoked. The gateway also ends the stream |
Key rotated. | The project's key was rotated |
Project deleted. / Account deleted. | Deleted |
Project suspended. / Account suspended. | Suspended |
Service is shutting down. | The gateway is restarting |
After a server-side close the stream simply ends, with no final event, and you cannot tell the reasons apart. Reconnect. After a revoke, an expiry or a suspension the reconnect fails with 401 or 403, which is the signal.
While the gateway shuts down it still accepts /agent/result and /agent/telemetry, so a call you are partway through can finish.
Timeouts and delivery guarantees
Timeout. The gateway waits 30 seconds for a result (the project's call timeout can lower it to as little as 1 second). When time runs out the caller gets Request for tool "<name>" timed out after <ms>ms. (resource "<uri>" and prompt "<name>" for the others). You are not told: your handler keeps running, and a result that arrives later is silently dropped with a 204. For longer work, return a job reference and add a second tool to poll it.
At most once. Each call is sent to exactly one agent, once. The gateway never retries a call and never re-routes it. So:
- If the stream drops after the gateway wrote the event but before you saw it, the call fails and your handler does not run.
- If the stream drops while your handler runs, it finishes but its result is lost, and the caller sees an error. A tool that changes state can therefore have happened even though the caller was told it failed. Make state-changing tools safe to repeat.
- The same call
idis never sent twice.
Result posts are idempotent. Posting the same result twice is harmless, so you may retry a failed result post (a network error or 5xx) while the call has not timed out. The Node.js SDK retries a failed post a few times.
Routing
- Many agents per project. Each process holds its own stream. The gateway shares each tool's calls in turn across the project's enabled agents that registered it. A call is never split. State must live in shared storage, not in agent memory.
- Lists come from one agent. Clients list tools from the longest-connected enabled agent, so give every agent of a project the same registration.
- Disabled agents keep their stream but get no new calls. Calls already running finish. Disabling lasts as long as the connection.
- Isolation. A call for one project is only ever sent to that project's agents.
- Some calls never reach you. A tool with a result cache is answered by the gateway, and calls whose arguments fail your schema are refused before dispatch.
Limits
The Free plan's values. See Plans and limits.
| Limit | Value | Where it bites |
|---|---|---|
| Registration body | 512 KB | /agent/connect 413 |
| Result and telemetry body | 4 MB | /agent/result, /agent/telemetry 413 |
| Tools per connection | 256 | 400 at connect |
| Schema size per tool | 65,536 bytes | 400 at connect |
| Resources per connection | 1,000 | 400 at connect |
| Tool and prompt names | letters, digits, _, -, 1 to 64 characters | 400 at connect |
| Live connections per project | 32 | 429 at connect |
| Connect attempts per minute per project | 60 | 429 at connect |
| Failed authentications per minute per address | 30 | 429 on any agent endpoint |
| Call timeout | 30 seconds | The call fails and a late result is dropped |
| Ping interval | 25 seconds | Liveness comment |
Worked transcript
This is a real session against a live gateway, captured with curl and a throwaway account that was deleted afterwards. Ids are as captured, and keys are replaced by $AGENT_KEY and $CLIENT_KEY. Use two shells, because the stream blocks.
1. Create a project and two keys in the dashboard: an Agent key for the agent and a Client key for the caller. Note the project id and set:
GATEWAY=https://api.spacefix.io
PROJECT=<project-id>
2. Shell A: connect and register. -N turns off curl's buffering and -i shows the headers. register.json is the body shown under Request.
curl -sN -i -X POST $GATEWAY/agent/connect \
-H "Authorization: Bearer $AGENT_KEY" -H 'Content-Type: application/json' -d @register.json
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no
Transfer-Encoding: chunked
event: ready
data: {"connectionId":"930b2ffd-173f-4dbd-b5f6-37d5e7dbf401"}
3. Shell B: a client lists the tools, then calls one with a trace context.
curl -s -X POST $GATEWAY/mcp/$PROJECT \
-H "Authorization: Bearer $CLIENT_KEY" -H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
event: message
data: {"result":{"tools":[{"name":"get_order","description":"Look up a single order by its id.","inputSchema":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}}]},"jsonrpc":"2.0","id":1}
curl -s -X POST $GATEWAY/mcp/$PROJECT \
-H "Authorization: Bearer $CLIENT_KEY" -H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' -H 'tracestate: vendor=demo' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_order","arguments":{"id":"1"}}}'
This request now waits. In shell A the event appears:
event: call
data: {"id":"2c3722a4-a76d-4269-8c35-0202b08f1d64","name":"get_order","args":{"id":"1"},"trace":{"traceparent":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01","tracestate":"vendor=demo","requestId":"af76b887-9fb2-40fd-aa25-1c237afbcab5","sentAt":1793411561765}}
4. Shell C: post the result.
curl -s -i -X POST $GATEWAY/agent/result \
-H "Authorization: Bearer $AGENT_KEY" -H 'Content-Type: application/json' \
-d '{"connectionId":"930b2ffd-173f-4dbd-b5f6-37d5e7dbf401","id":"2c3722a4-a76d-4269-8c35-0202b08f1d64","ok":true,"result":{"id":"1","item":"Widget","total":4200},"span":{"handlerMs":3,"inboundMs":2,"spanId":"b7ad6b7169203331"}}'
# HTTP/1.1 204 No Content
Shell B now completes. The object was wrapped as one text block:
event: message
data: {"result":{"content":[{"type":"text","text":"{\"id\":\"1\",\"item\":\"Widget\",\"total\":4200}"}]},"jsonrpc":"2.0","id":2}
Posting the same result again returns 204 and changes nothing.
5. A resource read, a failing call and a prompt, all in flight at once. Shell A received three events in a row (a read, which has no args, then a call, then a prompt), each with its own id and trace. The answers were posted in a different order and each returned 204:
# prompt
-d '{"connectionId":"930b2ffd-...","id":"7f4b0246-...","ok":true,"result":"Summarise this order politely: Widget"}'
# failing tool call
-d '{"connectionId":"930b2ffd-...","id":"0909f995-...","ok":false,"error":"No order 999. Ask the user to confirm the id."}'
# resource read
-d '{"connectionId":"930b2ffd-...","id":"50989b39-...","ok":true,"result":[{"id":"1","item":"Widget","total":4200}]}'
Each client got its own answer:
tool error: {"result":{"content":[{"type":"text","text":"No order 999. Ask the user to confirm the id."}],"isError":true},"jsonrpc":"2.0","id":4}
resource: {"result":{"contents":[{"uri":"orders://all","mimeType":"application/json","text":"[{\"id\":\"1\",\"item\":\"Widget\",\"total\":4200}]"}]},"jsonrpc":"2.0","id":3}
prompt: {"result":{"description":"Summarise an order.","messages":[{"role":"user","content":{"type":"text","text":"Summarise this order politely: Widget"}}]},"jsonrpc":"2.0","id":5}
A fourth request, get_order with "arguments":{"id":5}, was refused by the gateway without involving the agent (no event appeared):
{"result":{"content":[{"type":"text","text":"Input validation error: Invalid arguments for tool get_order: data/id must be string"}],"isError":true},"jsonrpc":"2.0","id":6}
6. Telemetry.
curl -s -i -X POST $GATEWAY/agent/telemetry \
-H "Authorization: Bearer $AGENT_KEY" -H 'Content-Type: application/json' \
-d '{"connectionId":"930b2ffd-173f-4dbd-b5f6-37d5e7dbf401","metrics":{"uptime":42,"rss":81264640,"heapUsed":14120000,"heapTotal":22000000,"cpu":0.03,"eventLoopDelay":10.4,"eventLoopUtilization":0.012,"tools":1,"calls":4,"errors":1,"inFlight":0,"peakInFlight":3,"avgMs":12}}'
# HTTP/1.1 204 No Content
7. A timeout. With the project's call timeout set to 2 seconds and the agent never answering, the client waited 2.018 seconds and got:
{"result":{"content":[{"type":"text","text":"Request for tool \"get_order\" timed out after 2000ms."}],"isError":true},"jsonrpc":"2.0","id":7}
8. Drop the stream with a call in flight. A call was started, then shell A was stopped with Ctrl-C. The waiting client got:
{"result":{"content":[{"type":"text","text":"Agent disconnected before returning a result."}],"isError":true},"jsonrpc":"2.0","id":8}
and the next tools/list got 503:
{"jsonrpc":"2.0","id":9,"error":{"code":-32001,"message":"No agent connected for server \"c497f628-a625-466c-8104-8c79cda1f05d\"."}}
9. Errors on the agent endpoints.
no Authorization header -> 401 {"error":"Missing API key."} WWW-Authenticate: Bearer realm="mcp"
unknown key -> 401 {"error":"Invalid API key."} WWW-Authenticate: Bearer realm="mcp", error="invalid_token", error_description="Invalid API key."
client key on /agent/connect -> 403 {"error":"This is a client key. Agents connect with an agent key."}
body sent without a JSON type -> 415 {"error":"Content-Type must be application/json."}
{} -> 400 {"error":"\"tools\" must be an array."}
{"tools":[{"name":"bad name"}]} -> 400 {"error":"Invalid tool name \"bad name\"."}
{"tools":[],"prompts":[{"name":"x y"}]} -> 400 {"error":"Invalid prompt \"x y\"."}
{"tools":[{"name":"t","inputSchema":{"type":"nonsense"}}]} -> 400 {"error":"Schema for \"t\" is not valid JSON Schema: type must be JSONType or JSONType[]: nonsense"}
{"tools":[],"resources":{}} -> 400 {"error":"\"resources\" must be an array."}
{"tools":[],"resources":[{"name":"x"}]} -> 400 {"error":"Invalid resource undefined."}
two resources with the same uri -> 400 {"error":"Duplicate resource \"a://b\"."}
{"tools":[],"resources":[{"uri":"a://b","name":5}]} -> 400 {"error":"Invalid name for resource \"a://b\"."}
a body of {bad -> 400 {"error":"Expected property name or '}' in JSON at position 1 (line 1 column 2)"}
/agent/result with a body of {} -> 204 (silently ignored)
10. Clean up. Delete the throwaway account under Settings, Danger zone. That ends any live stream and revokes its keys, and the keys then get 401 Invalid API key.
Next steps
- MCP endpoint: the other side, which clients call.
- Errors: every error a customer can see.
- OpenTelemetry: how the
tracefield becomes spans in your own backend. - Node.js SDK and Go SDK: the reference implementations of this protocol.