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

The agent protocol The agent opens a long-lived event stream with POST /agent/connect. Calls from MCP clients arrive as events on it; the agent answers each with POST /agent/result. The gateway sends a ping every 25 seconds. Agent your service + SDK Gateway Spacefix MCP client Claude, Cursor, … the response stays open: one long-lived stream POST /agent/connect (tools, resources, prompts) 200 text/event-stream + event: ready POST /mcp/<id> tools/call event: call {id, name, args} runs your handler POST /agent/result {id, ok, result} 204 tool result ": ping" every 25 s POST /agent/telemetry (optional)

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.

DirectionMechanismPurpose
Agent to gatewayPOST /agent/connect, response kept openRegister tools, resources and prompts, and receive events
Gateway to agentServer-sent events on that responseready, call, read, prompt, and : ping comments
Agent to gatewayPOST /agent/resultAnswer one call
Agent to gatewayPOST /agent/telemetryReport process metrics (optional)

Conventions

  • Base URL. The gateway's origin, for example https://api.spacefix.io. Append the three paths to it.
  • Bodies. Every POST to /agent/* must carry Content-Type: application/json, otherwise the gateway answers 415 {"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. sentAt is the gateway's clock and inboundMs is computed from yours. They are only comparable to within clock skew.

Authentication

Every agent endpoint takes the same header:

http
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.

SituationStatusBodyHeaders
No Authorization header401{"error":"Missing API key."}WWW-Authenticate: Bearer realm="mcp"
Key unknown, revoked or expired401{"error":"Invalid API key."}WWW-Authenticate: Bearer realm="mcp", error="invalid_token", error_description="Invalid API key."
A client key was used403{"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

http
POST /agent/connect HTTP/1.1
Authorization: Bearer <agent or full key>
Content-Type: application/json
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 }]
    }
  ]
}
FieldTypeRequiredMeaning
toolsarrayYes (may be empty)Tools this agent serves
resourcesarrayNo, default []Resources this agent serves
promptsarrayNo, default []Prompts this agent serves

Tool (tools[])

FieldTypeRequiredRules
namestringYesLetters, digits, _ and -, 1 to 64 characters. If two tools share a name, the later one replaces the earlier
descriptionstringNoShown to MCP clients and to the model, so write it for the model
inputSchemaobjectNoPlain 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"}
annotationsobjectNoHints 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[])

FieldTypeRules
uristringRequired, non-empty, at most 2,048 characters, unique within the registration. The read event's name is this uri
namestringSend one: it is the display name in resource listings. Must be a string if present
descriptionstringOptional
mimeTypestringOptional. Clients are told application/json when absent

At most 1,000 resources.

Prompt (prompts[])

FieldTypeRules
namestringThe same rule as a tool name
titlestringOptional. Shown in client menus
descriptionstringOptional
argumentsarrayOptional. 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.

  1. Authentication (see above).
  2. The project is not suspended, or 403 with a JSON-RPC-shaped body.
  3. The connect rate limit: 60 attempts a minute per project on Free. A failed attempt still spends a token.
  4. Content-Type: application/json, or 415.
  5. The body parses and is at most 512 KB (413 otherwise).
  6. tools is an array, then resources, then prompts.
  7. Each prompt has a valid name and array arguments.
  8. Each resource is valid (see below).
  9. At most 256 tools on Free, then each tool's name, schema size and schema validity.
  10. The project has fewer than 32 live connections on Free.
  11. Accepted: the gateway writes 200 and the stream headers, then the ready event.

Errors on connect

StatusBody (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
400Invalid prompt "<name>".The name fails the rule, or arguments is not an array
400Invalid resource <uri>.A resource without a usable uri (missing, empty, over 2,048 characters, or not an object)
400Invalid name for resource "<uri>".name is present but not a string
400Duplicate resource "<uri>".Two resources share a uri
400Too many resources: limit is 1000.More than 1,000 resources
400Too many tools: limit is <n>.More tools than the plan allows
400Invalid tool name "<name>".The name fails the rule
400Schema for "<name>" exceeds <n> bytes.inputSchema too large
400Schema for "<name>" is not valid JSON Schema: <detail>The schema failed to compile
400The JSON parser's messageThe body is not valid JSON
415Content-Type must be application/json.Missing or wrong Content-Type
413request entity too largeRegistration over 512 KB
429Rate limit exceeded: <n> requests per minute.Connect rate limit, with Retry-After
429Too 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
503Service is shutting down.The gateway is restarting

The 200 response

http
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: and data: are each one line, and data never 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 : ping and 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: or retry: field and no Last-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 several data: lines joined with \n.

Events

EventWhenPayload
readyOnce, immediately after the 200{ "connectionId": "<uuid>" }
callA client called a tool{ id, name, args, trace }
readA client read a resource{ id, name, trace }
promptA client asked for a prompt{ id, name, args, trace }
(comment) : pingEvery 25 secondsnone

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}}
FieldTypePresent onMeaning
idstringall threeThe call id. Quote it in /agent/result
namestringall threeFor call, the tool name. For read, the resource uri. For prompt, the prompt name
argsJSONcall, promptThe 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 {}
traceobjectall three, when there is trace contextAbsent when the call came from the dashboard's Playground

trace has these fields:

FieldTypeMeaning
traceparentstringA 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
tracestatestring, optionalThe client's tracestate, cut to 512 characters, present only when the client sent the traceparent
requestIdstringThe id shown as Request id on the Logs page. Put it on your spans as spacefix.request_id
sentAtnumberThe 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.

http
POST /agent/result HTTP/1.1
Authorization: Bearer <agent or full key>
Content-Type: application/json
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" }
}
FieldTypeRequiredMeaning
connectionIdstringYesFrom ready
idstringYesThe event's id
okbooleanYestrue for success. Anything else, including missing, counts as failure
resultJSONWhen ok is trueThe value to return. May be a string, number, object, array or null. If omitted the caller gets the text null
errorstringWhen ok is falseThe message returned to the caller. If omitted the gateway uses Tool failed.
spanobjectRecommendedHow 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.

FieldTypeValid rangeMeaning
handlerMsnumber0 to 3,600,000How long your handler took. Shown as Handler time in Logs
inboundMsnumber0 to 3,600,000How long the call took to reach you (yourNow - sentAt). Omit it when negative
spanIdstringexactly 16 lowercase hex charactersThe 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

Eventok: trueok: false
callA string becomes one text block. An object with a content array is passed through untouched. Anything else is JSON-encoded into a text blockThe tool result has isError: true and one text block containing error
readA string is the resource text, anything else is JSON-encoded, with the registered mimeType (default application/json)The client gets a JSON-RPC error
promptA string becomes one user message. An object with a messages array is passed through. Anything else is JSON-encoded into one user messageThe 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.

json
{
  "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
  }
}
FieldUnitMeaning
uptimesecondsProcess uptime
rssbytesResident memory
heapUsed, heapTotalbytesHeap in use and allocated. Send your runtime's closest equivalent
cpu0 to 1Share of one core used since the previous sample
eventLoopDelaymillisecondsMean scheduler delay over the window. Send the closest figure, or 0
eventLoopUtilization0 to 1Fraction of the window the scheduler was busy. 0 if not applicable
toolscountTools the agent exposes
calls, errorscountsCalls handled, and how many failed, since the previous sample (window counters, not totals)
inFlightcountCalls running at the instant of sampling
peakInFlightcountThe most simultaneous calls in the window
avgMsmillisecondsMean 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.

StepWhat to do
Any failureA non-200 from /agent/connect, a network error, or the stream ending for any reason counts as a failure
First delay1 second
GrowthDouble after each consecutive failure: 1, 2, 4, 8, 16, 30, 30, ... seconds. Ceiling 30 seconds
ResetBack to 1 second the moment a ready event arrives
JitterAdd 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-AfterIf the response carries one (a 429 does), wait at least that many seconds
StopStop 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:

MessageCause
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 id is 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.

LimitValueWhere it bites
Registration body512 KB/agent/connect 413
Result and telemetry body4 MB/agent/result, /agent/telemetry 413
Tools per connection256400 at connect
Schema size per tool65,536 bytes400 at connect
Resources per connection1,000400 at connect
Tool and prompt namesletters, digits, _, -, 1 to 64 characters400 at connect
Live connections per project32429 at connect
Connect attempts per minute per project60429 at connect
Failed authentications per minute per address30429 on any agent endpoint
Call timeout30 secondsThe call fails and a late result is dropped
Ping interval25 secondsLiveness 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:

bash
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.

bash
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.

bash
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}
bash
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.

bash
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:

bash
# 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.

bash
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