Skip to content

Inside the native server

Section 6 · From docs/MCP_INTERNALS.md on GitHub

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:

  1. initialize on a modern session raises -32022; otherwise it is routed as legacy.
  2. 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.
  3. A request without the envelope on a modern session raises -32602.
  4. Before initialize only ping is 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):

  1. Host: when allowed_hosts is given, or the bind is loopback (then the loopback names are the list), a missing Host or one that matches no pattern gives (421, "Invalid Host header"). Other binds skip this step.
  2. Origin: absent passes. Otherwise it must match the origins (default the loopback origins, plus extra_origins, which the native server fills from cors_origins) or be same-origin (urlsplit(origin).netloc equal to Host, case-insensitive), else (403, "Forbidden: invalid Origin").

The allow-lists are fnmatch patterns compared case-insensitively.

_post:

  1. Content type, Accept and JSON parsing.
  2. A batch containing any 2026 envelope: 400.
  3. A single 2026 request: _modern_rejection applies the SDK's ladder, first failure wins, HTTP 400 with the JSON-RPC error: both envelope keys present (-32602); then MCP-Protocol-Version present and equal to the envelope version, Mcp-Method equal to the method, and for tools/call, prompts/get and resources/read Mcp-Name (decoded from =?base64?...?= when needed) equal to name or uri (-32020); then the version a string (-32602) and supported (-32022). A one-shot unregistered session then handles it.
  4. Unsupported MCP-Protocol-Version: 400.
  5. Stateless mode: a one-shot session, pre-initialized unless the message is initialize.
  6. initialize: new session, Mcp-Session-Id header.
  7. Otherwise look up the session (400 / 404) and check the version header against the negotiated version.
  8. Only notifications and responses: handle, answer 202.
  9. 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.