Inside the native server¶
6.1 jsonrpc.py¶
| Constant | Value |
|---|---|
HANDSHAKE_VERSIONS |
2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25 |
MODERN_VERSIONS |
2026-07-28 |
BATCH_VERSIONS |
2024-11-05, 2025-03-26 |
DEFAULT_HTTP_VERSION |
2025-03-26 (HTTP request without MCP-Protocol-Version) |
CACHEABLE_METHODS |
the four list methods, resources/read, server/discover |
| Envelope keys | io.modelcontextprotocol/protocolVersion, .../clientInfo, .../clientCapabilities, .../logLevel, .../serverInfo, .../subscriptionId |
| Codes | -32700, -32600, -32601, -32602, -32603, -32002, -32020, -32022 |
LOG_LEVELS |
debug, info, notice, warning, error, critical, alert, emergency |
valid_id accepts strings and integers (not booleans or null). dumps produces compact
single-line JSON, which stdio framing needs.
6.2 protocol.py¶
NativeMCPServer(layer, *, page_size=100, principal_resolver=None, default_log_level="info",
cache_ttl_ms=0, cache_scope="private", title="cloudg") holds what is shared across
connections: the layer, the resolver, the list page size, the 2026 cache hints, the set of
open listen streams and a weak set of handshake-era sessions for change fan-out. It registers
a weakly referenced listener on the layer and binds to the first running loop it sees.
create_session(*, transport, send, principal, session_id, stateless_version, register)
returns a Session. send delivers server-initiated messages; principal defaults to the
resolver's answer for the transport; stateless_version makes a pre-initialized
handshake-era session (stateless HTTP); register=False keeps one-shot sessions out of the
fan-out.
Session state:
| Attribute | Meaning |
|---|---|
era |
None until the first request, then "legacy" or "modern" |
protocol_version |
negotiated (legacy) or last seen (modern) version |
init_responded, initialized |
initialize answered; notifications/initialized received |
client_info, client_capabilities |
from initialize or the envelope |
log_level |
threshold for log notifications (legacy era) |
subscriptions |
URIs from resources/subscribe |
_inflight, _cancelled |
request id to task; ids cancelled by the client |
handle(message, *, sink=None, principal=None) accepts one decoded message or a list. Lists
are rejected unless the session is legacy with a batch version; allowed batches run their
items concurrently with asyncio.gather. For a single message _handle_one validates the
envelope (jsonrpc: "2.0", string method, valid id, object params), ignores stray responses,
handles notifications, and sends requests to _handle_request.
_handle_request registers the current task under the request id (for cancellation), calls
_route to build a _RequestCtx, looks up the implementation in
_METHODS[(modern, method)], stamps 2026 results, and returns the response. A
JSONRPCError becomes an error response; a CancelledError for an id in _cancelled
returns None (no response, as MCP requires for cancelled requests) after uncancel();
anything else is logged and answered with -32603 "Internal error".
_route implements the era rules:
initializeon a modern session raises -32022; otherwise it is routed as legacy.- A request with the 2026 envelope on a legacy session raises -32600. On a new or modern
session it needs
clientCapabilities(-32602), a string version (-32602) and a supported version (-32022), then marks the session modern and records client info, capabilities and the per-request log level. - A request without the envelope on a modern session raises -32602.
- Before
initializeonlypingis accepted (-32600 otherwise).
_stamp_modern adds resultType: "complete", ttlMs and cacheScope for cacheable
methods, and io.modelcontextprotocol/serverInfo in _meta.
Method tables: common to both eras are the list methods, tools/call, resources/read,
prompts/get and completion/complete. Legacy only: initialize, ping,
resources/subscribe, resources/unsubscribe, logging/setLevel. Modern only:
server/discover and subscriptions/listen.
Notifications: notifications/initialized sets initialized; notifications/cancelled
cancels the task registered under requestId; everything else is ignored.
Progress and logs: _tool_context(rc) reads progressToken from the request's _meta and
returns a ToolContext whose progress callback sends notifications/progress (only with a
valid token and only increasing values) and whose log callback sends notifications/message
when the level reaches the threshold: the session's logging/setLevel value on the legacy
era, the request's logLevel on the modern era, where no level means no log notifications.
rc.notify writes to the request's sink when the transport gave one (an SSE stream) and to
the session's send otherwise.
Listen streams: _m_listen honors toolsListChanged, promptsListChanged,
resourcesListChanged and resourceSubscriptions, sends
notifications/subscriptions/acknowledged, then forwards events from a bounded queue
(1024 entries; overflow is dropped with a warning) until close_listens() puts None on the
queue, when it returns its final result {"_meta": {subscriptionId}}. Every notification
carries the listen request's id as io.modelcontextprotocol/subscriptionId.
Change dispatch: _on_layer_change hops to the server's loop when called from another
thread; _dispatch_change expands the event like ChangeFanout, feeds listen streams that
want it, and calls _emit_change on every registered legacy session, which sends list
changes to initialized sessions and resource updates only for subscribed URIs.
Pagination: paginate(kind, items, cursor) decodes the cursor (URL-safe base64 of
{"k": kind, "o": offset}, padding stripped), rejects a wrong kind, negative offset or
garbage with -32602 "Invalid cursor", and returns a page plus the next cursor.
6.3 stdio.py¶
protected_stdout() flushes sys.stdout, duplicates fd 1 twice (one copy for the protocol,
one to restore later), points fd 1 at stderr with dup2, sets sys.stdout = sys.stderr, and
yields an unbuffered binary stream on the protocol copy. On exit it restores fd 1 and
sys.stdout. When stdout has no file descriptor (pytest capture, some IDEs) it yields the
current stdout unchanged.
run_stdio_async(server, *, stdin=None, stdout=None, principal=None, protect_stdout=True,
shutdown_grace=5.0) creates one session with Principal.local() unless told otherwise,
starts a daemon reader thread (readline loop, lines handed to the loop with
call_soon_threadsafe; a thread works the same on POSIX pipes and Windows consoles), and a
writer task that serializes every outgoing message through one queue and writes it with
asyncio.to_thread. Lines that are not JSON get a -32700 response. initialize,
notifications and responses are processed inline, in order; other requests and batches become
tasks. At EOF the finally block closes listen streams, waits up to shutdown_grace for
pending tasks, cancels the rest, closes the session and the server, and drains the writer.
6.4 http.py¶
NativeHTTPServer(server, config) with HTTPConfig fields: host, port, path,
enable_sse, sse_path (/sse), message_path (/messages/), allowed_origins,
allowed_hosts, auth, json_response, stateless, max_body_bytes (4 MiB),
session_idle_timeout (3600 s), max_sessions (1000), keepalive_interval (15 s),
cors_origins, health_path (/healthz).
Request reading (_read_request): headers up to 64 KiB (StreamReader limit), repeated
headers joined with ,, bodies by Content-Length or chunked encoding, both capped by
max_body_bytes. A connection serves requests in a loop while keep-alive holds; each read
waits at most 120 s.
_dispatch order: self.guard.check(host, origin), then OPTIONS, health, bearer auth,
principal resolution (the token principal goes in as RequestInfo.principal, so a custom
resolver can see and replace it), then routing by path and method.
OriginHostGuard(bind_host, allowed_origins=None, allowed_hosts=None, extra_origins=()) is the
check every flavor shares. check(host, origin) returns None or (status, message):
- Host: when
allowed_hostsis given, or the bind is loopback (then the loopback names are the list), a missingHostor one that matches no pattern gives(421, "Invalid Host header"). Other binds skip this step. - Origin: absent passes. Otherwise it must match the origins (default the loopback origins,
plus
extra_origins, which the native server fills fromcors_origins) or be same-origin (urlsplit(origin).netlocequal toHost, case-insensitive), else(403, "Forbidden: invalid Origin").
The allow-lists are fnmatch patterns compared case-insensitively.
_post:
- Content type,
Acceptand JSON parsing. - A batch containing any 2026 envelope: 400.
- A single 2026 request:
_modern_rejectionapplies the SDK's ladder, first failure wins, HTTP 400 with the JSON-RPC error: both envelope keys present (-32602); thenMCP-Protocol-Versionpresent and equal to the envelope version,Mcp-Methodequal to the method, and fortools/call,prompts/getandresources/readMcp-Name(decoded from=?base64?...?=when needed) equal tonameoruri(-32020); then the version a string (-32602) and supported (-32022). A one-shot unregistered session then handles it. - Unsupported
MCP-Protocol-Version: 400. - Stateless mode: a one-shot session, pre-initialized unless the message is
initialize. initialize: new session,Mcp-Session-Idheader.- Otherwise look up the session (400 / 404) and check the version header against the negotiated version.
- Only notifications and responses: handle, answer 202.
- Requests:
_respond.
_respond decides between JSON and SSE (see the table in MCP.md section 6.2). In SSE mode
it starts session.handle(..., sink=queue.put) as a task, streams queued notifications as
they come, sends a keep-alive comment when nothing arrived for keepalive_interval, then
flushes the queue, writes the response and the terminating chunk. If the client disconnects,
2026 requests and listen streams are cancelled; handshake-era requests keep running, since
that era lets a server finish work after the stream drops.
GET opens _pump, which drains the session's outbox (a bounded deque fed by the session's
send) as SSE and sends keep-alives; None in the outbox ends the stream. DELETE removes the
session and pushes None. A reaper task expires sessions idle longer than
session_idle_timeout that have no open stream. The legacy SSE transport creates a session on
GET /sse, emits the endpoint event, and POSTs to /messages/?session_id=... handle the
message (requests other than initialize concurrently) and push responses to the stream.
start() warns when bound to a non-loopback address without auth, binds, records
self.bound (port 0 gives a free port) and starts the reaper; serve_forever() runs until
cancelled and then aclose()s: listen streams closed, sessions ended, sockets closed,
connection tasks cancelled. run_http_async(server, config) is start plus serve.
6.5 auth.py¶
TokenAuth stores (sha256(token), Principal) pairs. add(token, value) accepts a
Principal (kept as given), a comma-separated role string or an iterable of roles; the last
two always get default added. lookup(token) compares the probe digest with every entry
using hmac.compare_digest and returns a copy of the matching principal, so callers may
mutate it. authenticate(header) extracts the bearer token first.
parse_token_spec(spec) reads TOKEN[:ROLES[:ID]] from the right (spec.rsplit(":", 2)), so
a token containing : needs both fields, possibly empty (abc:def::); env:VAR[:ROLES[:ID]]
takes the token from the environment. Roles always include default; the id defaults to
token- plus the first 8 hex characters of the token's SHA-256. MCP.md section 6.3 has a
table of real parses. default_principal_resolver is described in MCP.md section 8.8;
anonymous_principal() is Principal(id="anonymous", roles={"default"}).
PRINCIPAL_SCOPE_KEY = "cloudg.principal" is the ASGI scope key shared with
TokenAuthASGIMiddleware.