Transports and protocol versions¶
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
- 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.