Skip to content

Transports and protocol versions

Section 6 · From docs/MCP.md on GitHub

6.1 stdio

With --transport stdio the client launches the server and talks newline-delimited JSON-RPC over its stdin and stdout. stdout carries protocol messages and nothing else; one stray print() from a handler or a library would corrupt the stream and the client would drop the connection. Each flavor guards this:

Flavor How stdout is protected
native protected_stdout() duplicates the real stdout file descriptor for the protocol, then points sys.stdout and fd 1 at stderr for the life of the server
sdk, mcp 2.x the SDK's stdio_server() diverts fd 1 to stderr itself
sdk, mcp 1.x cloudg wraps the SDK's stdio server in the same protected_stdout()
fastmcp run_async(transport="stdio", show_banner=False), so fastmcp's banner stays off stdout

Logging is left to the host process. serve sets the level of the cloudg logger and, only when no handler is configured anywhere (neither on the root logger nor on cloudg), gives cloudg a stderr handler; it never touches root handlers. A handler bound to sys.stdout cannot reach the protocol stream anyway on the native and SDK stdio servers, because file descriptor 1 already points at stderr while they serve. The root cloudg command prints its banner to stderr when the subcommand is mcp. The test suite has a tool that calls print() and os.write(1, ...) during a stdio session and checks that the protocol stream stays clean.

On the native stdio server, stdin is read by a daemon thread and responses are written by a single writer task, so messages never interleave. initialize, notifications and responses are handled in order; every other request runs as its own task, so ping and notifications/cancelled work while a long tool call is in flight. At EOF the server closes listen streams, gives in-flight requests 5 seconds (shutdown_grace), cancels what is left and exits.

A raw session, to show the framing (output trimmed):

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"sh","version":"0"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"dataset_summary","arguments":{}}}' \
| cloudg-mcp serve --flavor native --dataset prod=inventory/inventory-map.json
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":true},"resources":{"subscribe":true,"listChanged":true},"prompts":{"listChanged":true},"logging":{},"completions":{}},"serverInfo":{"name":"cloudg", ...
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"{\n  \"dataset\": \"prod\",\n  \"kind\": \"inventory\", ...

stderr stayed empty. Bearer tokens are ignored on stdio (the local user launched the process), and the caller is Principal.local().

6.2 Streamable HTTP (native flavor)

The native HTTP server is a small HTTP/1.1 implementation on asyncio.start_server. One endpoint (--path, default /mcp) takes POST, GET and DELETE; /healthz answers GET without authentication.

POST carries one JSON-RPC message, or a batch where the negotiated protocol version allows it. The response mode is chosen per request:

Situation Response
The body holds only notifications or responses 202 Accepted, empty body
subscriptions/listen SSE stream (requires Accept: text/event-stream, else 406)
tools/call, resources/read or prompts/get, client accepts SSE, no --json-response SSE stream: progress and log notifications for that request, then the response
Client accepts only JSON, or --json-response, or any other method application/json
2026-07-28 request whose method does not exist 404 with JSON-RPC -32601

SSE responses carry Cache-Control: no-cache, no-transform and X-Accel-Buffering: no and send a : keepalive comment every 15 seconds while the handler works. Each message is an event: message frame.

Requests are checked in this order; the first failing check answers:

Check Status When
Open connections 503, connection closed More than HTTPConfig.max_connections (512) at once
Request line and headers 400, or 431 above 64 KiB of headers The connection is dropped without an answer when they take longer than header_timeout (30 seconds, also the keep-alive idle limit)
Body 413 above 4 MiB (HTTPConfig.max_body_bytes) Content-Length or chunked. The connection is dropped when the body takes longer than body_timeout (120 seconds)
Host header against the allow-list 421 Default list: localhost, 127.0.0.1 and [::1], with or without a port, applied when bound to a loopback address, where a request without Host is refused too. On other binds the check is off unless you pass --allowed-host
Origin header against the allow-list 403 Only when Origin is present. Default: http(s)://localhost, 127.0.0.1 and [::1] with any port, whatever the bind address. --cors-origin values are added. A same-origin request (Origin host:port equal to Host) is accepted when the Host check above is on. On a non-loopback bind without --allowed-host it is refused: a DNS-rebinding attacker controls both headers there, so a matching pair proves nothing
OPTIONS preflight 204 with CORS headers, or 405 when CORS is off
/healthz 200 {"status":"ok"} Before authentication
Bearer token 401 with WWW-Authenticate: Bearer realm="cloudg-mcp" When tokens are configured and the header is missing or wrong
Method and path 405 for other methods on the endpoint, 404 for any other path
Content-Type: application/json 415 POST
Accept 406 When it lists neither JSON nor SSE (an empty Accept counts as both)
JSON parse 400 with -32700
2026-07-28 envelope and headers 400 with -32020, -32602 or -32022 See 6.4
MCP-Protocol-Version (handshake era) 400 Unsupported value, or different from the version the session negotiated
Session 400 without Mcp-Session-Id, 404 unknown or another principal's Not in stateless mode
Session caps 503 More than 1000 live sessions (max_sessions). A principal at max_sessions_per_principal (100) has its least recently used idle session ended to make room, and gets 503 only when all of them are streaming

Sessions (handshake era, not --stateless): the initialize response carries Mcp-Session-Id (32 random hex characters). Later requests must send it. A session is bound to the principal id that created it; another principal presenting the id gets 404. Sessions expire after an hour without requests (session_idle_timeout) unless a GET stream is open. GET with Accept: text/event-stream and the session id opens the session's standalone stream, which carries list-changed and resource-updated notifications; a second GET on the same session gets 409. DELETE ends the session.

--stateless drops sessions entirely. initialize is still answered (without a session id), every other request runs in a throwaway session pre-initialized at the version from MCP-Protocol-Version, or 2025-03-26 when the header is missing, and GET and DELETE return

  1. Stateless mode cannot deliver standalone notifications.

--transport sse additionally serves the deprecated 2024-11-05 HTTP+SSE transport: GET /sse opens a stream whose first event is endpoint with /messages/?session_id=..., and the client POSTs messages there (202); responses arrive on the stream.

A walk-through with curl against cloudg-mcp serve --transport http --flavor native --port 8799 --dataset prod=inventory/inventory-map.json --auth-token s3cret:analyst:alice --audit-log audit-http.jsonl:

$ curl -si -X POST http://127.0.0.1:8799/mcp -H 'Content-Type: application/json' \
    -H 'Accept: application/json, text/event-stream' \
    -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
HTTP/1.1 401 Unauthorized
Server: cloudg-mcp
Content-Type: application/json
Content-Length: 84
WWW-Authenticate: Bearer realm="cloudg-mcp"

{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Unauthorized"}}

The same request with -H 'Authorization: Bearer s3cret':

HTTP/1.1 200 OK
Server: cloudg-mcp
Content-Type: application/json
Content-Length: 890
Mcp-Session-Id: 3163cbab926770195146bffd05643926

{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true},"resources":{"subscribe":true,"listChanged":true},"prompts":{"listChanged":true},"logging":{},"completions":{}},"serverInfo":{"name":"cloudg","version":"0.6.0","title":"cloudg"},"instructions":"cloudg exposes a multi-cloud ...

Then notifications/initialized (202 Accepted, empty body) and a tool call. With both media types accepted, tools/call streams:

$ curl -si -N -X POST http://127.0.0.1:8799/mcp -H 'Authorization: Bearer s3cret' \
    -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
    -H "Mcp-Session-Id: $SID" -H 'MCP-Protocol-Version: 2025-11-25' \
    -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"count_assets","arguments":{"group_by":"provider"},"_meta":{"progressToken":"p1"}}}'
HTTP/1.1 200 OK
Server: cloudg-mcp
Content-Type: text/event-stream
Cache-Control: no-cache, no-transform
Transfer-Encoding: chunked
X-Accel-Buffering: no

event: message
data: {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"..."}],"isError":false,"structuredContent":{"dataset":"prod","group_by":"provider","total":44,"group_count":3,"groups":{"AWS":36,"AZURE":5,"GCP":3},"truncated":false},"_meta":{"cloudg/transforms":{...,"provenance":{...,"principal_roles":["analyst","default"]}}},"cloudg/duration_ms":14}}}

Note principal_roles: ["analyst", "default"]: a token principal gets the roles in its spec plus default, so policy rules written for the default role apply to it.

The failure cases from the table, all against the same server:

--- Origin: https://evil.example
HTTP/1.1 403 Forbidden
{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Forbidden: invalid Origin"}}
--- Host: attacker.example:8799
HTTP/1.1 421 Misdirected Request
{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Invalid Host header"}}
--- Origin: http://127.0.0.1:8799 (same origin as the Host)
HTTP/1.1 200 OK
{"jsonrpc":"2.0","id":9,"result":{}}
--- no Mcp-Session-Id
HTTP/1.1 400 Bad Request
{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Bad Request: Mcp-Session-Id header is required"}}
--- Mcp-Session-Id: deadbeef
HTTP/1.1 404 Not Found
{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Session not found"}}
--- MCP-Protocol-Version: 2025-06-18 on a 2025-11-25 session
HTTP/1.1 400 Bad Request
{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "MCP-Protocol-Version 2025-06-18 does not match the negotiated version 2025-11-25"}}
--- MCP-Protocol-Version: 2099-01-01
HTTP/1.1 400 Bad Request
{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Unsupported MCP-Protocol-Version: 2099-01-01"}}
--- Content-Type: text/plain
HTTP/1.1 415 Unsupported Media Type
{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Content-Type must be application/json"}}
--- GET with Accept: application/json
HTTP/1.1 406 Not Acceptable
{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Not Acceptable: GET requires Accept: text/event-stream"}}
--- PUT
HTTP/1.1 405 Method Not Allowed
Allow: GET, POST, DELETE
--- a JSON-RPC batch on a 2025-11-25 session
HTTP/1.1 200 OK
{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"JSON-RPC batches are not supported by protocol version 2025-11-25"}}
--- DELETE with the session id, then a ping with the same id
HTTP/1.1 200 OK
HTTP/1.1 404 Not Found
{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Session not found"}}

(Headers other than the status line trimmed.)

6.3 Bearer tokens and roles

--auth-token specs map a static token to a principal. The roles and id fields are read from the right, and every token principal carries default in addition to its roles. Results of cloudg.mcp.native.auth.parse_token_spec for a few specs:

Spec Token Principal id Roles
s3cret s3cret token-1ec1c26b (first 8 hex of sha256 of the token) default
s3cret:analyst s3cret token-1ec1c26b analyst, default
s3cret:analyst,admin:alice s3cret alice admin, analyst, default
s3cret::alice s3cret alice default
env:CLOUDG_MCP_TOKEN:analyst:ci-bot value of $CLOUDG_MCP_TOKEN ci-bot analyst, default
abc:def:: abc:def token-ec595285 default
abc:def:analyst: abc:def token-ec595285 analyst, default
abc:def abc token-ba7816bf def, default

The last row is the trap: a token that contains a colon must be written in the full three-field form, with empty fields where you have nothing to say. Environment variable names cannot contain colons. Tokens are kept only as SHA-256 digests and compared with hmac.compare_digest against every entry, so the time taken does not reveal which token matched. Roles feed the policy's roles: section; see MCP_PRIVACY.md. The audit log records the principal id, so give tokens an explicit :ID.

In Python, TokenAuth does the same thing:

from cloudg.mcp import Principal
from cloudg.mcp.native.auth import TokenAuth

auth = TokenAuth({
    "t-analyst": "analyst",                                  # roles as a string
    "t-ops": ["ops", "analyst"],                              # or an iterable
    "t-admin": Principal(id="admin-1", roles={"admin"}),      # or a full principal
}, required=True)
auth.authenticate("Bearer t-ops")   # -> Principal(id='token-...', roles={'analyst', 'default', 'ops'}, ...)
auth.authenticate("Bearer nope")    # -> None

Roles given as a string or an iterable get default added; a Principal you build yourself is used exactly as given (the Starlette example in 8.6 shows principal_roles: ["analyst"] for that reason).

With required=False a request without a valid token is let through as the anonymous principal instead of getting 401.

6.4 The two protocol eras

MCP has two incompatible ways of starting a conversation, and cloudg serves both on the same server object:

Handshake era Stateless era
Versions 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25 2026-07-28
Start initialize, then notifications/initialized none; every request carries params._meta with io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities
Discovery the initialize result server/discover
HTTP sessions Mcp-Session-Id none
HTTP request headers MCP-Protocol-Version optional, checked against the negotiated version when sent MCP-Protocol-Version and Mcp-Method required, Mcp-Name for tools/call, prompts/get, resources/read
Change notifications GET stream (HTTP) or the stdio connection; resources/subscribe subscriptions/listen stream
Logging logging/setLevel per session io.modelcontextprotocol/logLevel per request; no log notifications without it
ping yes removed (404 with -32601)
Batches 2024-11-05 and 2025-03-26 only never
Result extras none resultType: "complete"; ttlMs and cacheScope on list, read and discover results; io.modelcontextprotocol/serverInfo in _meta
Resource not found -32002 -32602

The first request on a connection decides its era. After that, the native server refuses the other era's requests on the same connection: initialize on a 2026 connection gets -32022, and a 2026 envelope on a handshake connection gets -32600. Before initialize, a handshake connection accepts only ping and answers everything else with Server not initialized: send initialize first (-32600).

Version negotiation:

Client sends Native server answers
initialize with one of the four handshake versions that version
initialize with an unknown version, or 2026-07-28 2025-11-25 (latest handshake version)
A 2026-07-28 envelope handled at 2026-07-28
An envelope with another version -32022 with {"supported": ["2026-07-28"], "requested": ...} (HTTP 400)
An envelope without clientCapabilities -32602 (HTTP 400)

On HTTP, every 2026 POST must also carry headers that repeat parts of the body, as in mcp 2.3: MCP-Protocol-Version (equal to the envelope's version) and Mcp-Method (equal to the method), plus Mcp-Name for tools/call, prompts/get and resources/read (equal to the name or uri parameter; non-ASCII values use the =?base64?...?= form). The native server checks in the same order as the SDK, and the first failure answers with HTTP 400: the envelope keys (-32602), then the headers (-32020), then the version (-32602 when it is not a string, -32022 when it is unsupported). Real exchanges with the native server:

$ M='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}'
$ curl -si -X POST $URL -H 'Authorization: Bearer s3cret' -H 'Content-Type: application/json' \
    -H 'Accept: application/json' -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"server/discover\",\"params\":{$M}}"
HTTP/1.1 400 Bad Request
{"jsonrpc": "2.0", "id": 1, "error": {"code": -32020, "message": "MCP-Protocol-Version header does not match the request envelope's protocol version"}}

$ curl -s -X POST $URL -H 'Authorization: Bearer s3cret' -H 'Content-Type: application/json' \
    -H 'Accept: application/json' -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: server/discover' \
    -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"server/discover\",\"params\":{$M}}"
{"jsonrpc":"2.0","id":1,"result":{
  "supportedVersions":["2024-11-05","2025-03-26","2025-06-18","2025-11-25","2026-07-28"],
  "capabilities":{"tools":{"listChanged":true},"resources":{"subscribe":true,"listChanged":true},
                  "prompts":{"listChanged":true},"logging":{},"completions":{}},
  "instructions":"cloudg exposes a multi-cloud ...",
  "resultType":"complete","ttlMs":0,"cacheScope":"private",
  "_meta":{"io.modelcontextprotocol/serverInfo":{"name":"cloudg","version":"0.6.0","title":"cloudg"}}}}

--- tools/call with MCP-Protocol-Version and Mcp-Method but no Mcp-Name
HTTP/1.1 400 Bad Request
{"jsonrpc": "2.0", "id": 2, "error": {"code": -32020, "message": "Mcp-Name header does not match the request body's 'name' parameter"}}
--- the same with -H 'Mcp-Name: count_assets'
structuredContent.groups {'AWS': 36, 'AZURE': 5, 'GCP': 3}, resultType complete,
_meta serverInfo {'name': 'cloudg', 'version': '0.6.0', 'title': 'cloudg'}
--- Mcp-Method: tools/list on a prompts/list body
HTTP/1.1 400 Bad Request
{"jsonrpc": "2.0", "id": 4, "error": {"code": -32020, "message": "Mcp-Method header does not match the request body's method"}}
--- envelope without clientCapabilities
HTTP/1.1 400 Bad Request
{"jsonrpc": "2.0", "id": 5, "error": {"code": -32602, "message": "params._meta is missing the required envelope key(s): io.modelcontextprotocol/clientCapabilities"}}
--- envelope and MCP-Protocol-Version both 2027-01-01, Mcp-Method set
HTTP/1.1 400 Bad Request
{"jsonrpc": "2.0", "id": 6, "error": {"code": -32022, "message": "Unsupported protocol version: 2027-01-01", "data": {"supported": ["2026-07-28"], "requested": "2027-01-01"}}}
--- ping with an envelope
HTTP/1.1 404 Not Found
{"jsonrpc":"2.0","id":7,"error":{"code":-32601,"message":"Method not found","data":"ping"}}
--- a batch of envelope requests
HTTP/1.1 400 Bad Request
{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "JSON-RPC batches are not supported by protocol version 2026-07-28"}}

Progress works the same in both eras: put a progressToken in params._meta and the notifications arrive on the request's SSE stream. ontology_stats reports progress while it builds the ontology for the first time:

event: message
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"tok-1","progress":0,"total":1,"message":"building ontology for 44 assets"}}

event: message
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"tok-1","progress":1,"total":1,"message":"ontology built"}}

event: message
data: {"jsonrpc":"2.0","id":11,"result":{"content":[{"type":"text","text":"{\n  \"dataset\": \"prod\",\n  \"built_now\": true,\n  \"total_triples\": 1185, ...

Change notifications on the 2026 era come from subscriptions/listen. The request names what it wants; the stream first acknowledges, then sends one notification per change, each tagged with the listen request's id. Here a second client loaded a dataset while the stream was open:

$ curl -s -N -X POST $URL ... -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: subscriptions/listen' \
    -d '{"jsonrpc":"2.0","id":"listen-1","method":"subscriptions/listen","params":{"notifications":{"resourcesListChanged":true,"toolsListChanged":true,"resourceSubscriptions":["cloudg://workspace"]},'"$M"'}}'
event: message
data: {"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{"notifications":{"toolsListChanged":true,"resourcesListChanged":true,"resourceSubscriptions":["cloudg://workspace"]},"_meta":{"io.modelcontextprotocol/subscriptionId":"listen-1"}}}

event: message
data: {"jsonrpc":"2.0","method":"notifications/resources/list_changed","params":{"_meta":{"io.modelcontextprotocol/subscriptionId":"listen-1"}}}

event: message
data: {"jsonrpc":"2.0","method":"notifications/resources/updated","params":{"uri":"cloudg://workspace","_meta":{"io.modelcontextprotocol/subscriptionId":"listen-1"}}}

The verified end-to-end matrix with the official SDK client (mcp.Client from mcp 2.3.0, launching cloudg mcp serve over stdio): mode="auto" negotiated 2026-07-28 against both the native and the SDK flavor, and mode="legacy" negotiated 2025-11-25 against the SDK flavor. In every run the client listed 73 tools under standard (61 under strict today), 14 resources (the catalog's 13 plus cloudg://metrics), 11 templates and 11 prompts, and the ARN returned by find_assets (pseudonymized under strict) worked as the ref of a follow-up get_asset call.