MCP Specification Compliance
This page records this server's compliance status against the Model
Context Protocol specification, and the decisions behind what was and
was not adopted from the latest revision, 2026-07-28. It exists so a
future contributor can see why a given piece of the spec is or is not
implemented without re-deriving the reasoning.
Summary
This server is dual-era: it implements the handshake-based
("legacy") revisions 2024-11-05, 2025-03-26, 2025-06-18 and
2025-11-25 (a connection-scoped initialize handshake), and the
current revision, 2026-07-28 (stateless, per-request negotiation), at
the same time, on both transports. A request is served under whichever
model it uses:
- A request whose
params._metacarriesio.modelcontextprotocol/protocolVersionis served under the2026-07-28model. On HTTP, anMCP-Protocol-Versionheader also marks a request modern even when the body doesn't, unless the header names one of the legacy revisions above. A legacy Streamable HTTP client of2025-06-18or later must send that header, with its negotiated legacy revision, on every request after the handshake, so the header's value rather than its presence decides (#277). Any other value, including2026-07-28itself, marks the request modern; this catches a client that means to be modern but sends a body with no usable_meta.protocolVersion(omitted, or the reserved key misspelled), which otherwise would have been silently served as legacy with no indication the header was ignored. SeeisModernHTTPininternal/mcp/modern.go. - A request without either signal, including every
initializehandshake, is served under the legacy model, with the revision settled by the handshake as described under "Legacy revision negotiation" below.
This matches the versioning specification's own guidance: "A dual-era
server MAY serve both eras concurrently on the same endpoint or
process." Adopting 2026-07-28 in full therefore did not require
removing anything: the legacy path is unchanged, byte-for-byte, for a
request that does not opt into the modern one.
What is implemented
- Legacy revision negotiation.
initializeanswers with the newest supported legacy revision at or below the one the client requests, with2024-11-05for a request older than every supported revision, and with2025-11-25(ProtocolVersionininternal/mcp/server.go) when the client omitsprotocolVersion. The server originally answered2024-11-05whatever was requested, which a strict Streamable HTTP client with no fallback, such as OpenAI Codex, refused outright because that revision predates the transport (#277). Only handshake-based revisions belong insupportedProtocolVersions;2026-07-28has no handshake, so it is served by the per-request path above rather than negotiated. server/discover(stdio and HTTP) — required by the spec for every server. ReturnssupportedVersions,capabilities, andserverInfo. Subject to the same per-request validation as every other modern method when called as one (with_meta.protocolVersionset); a client that does not yet know what this server supports can instead call it exactly like a legacy request (no_metaat all) and get the same answer with no validation to satisfy first, sincehandleDiscover/handleDiscoverHTTPdo not vary their response by era.- Per-request
_metanegotiation —io.modelcontextprotocol/protocolVersion,clientInfo,clientCapabilities, andlogLevelare parsed from every request'sparams._meta. A request whoseprotocolVersionis not2026-07-28(the only modern revision this server implements) getsUnsupportedProtocolVersionError(-32022); a request missing the requiredclientCapabilitiesfield gets-32602 Invalid params. resultTypeon every modern result (always"complete": this server never implements Multi Round-Trip Requests, so"input_required"never applies), and_meta.serverInfoon every modern result.ttlMs/cacheScopeon the results the spec classifies as cacheable (tools/list,resources/list,prompts/list,resources/read). This server's registries are populated once at startup and never change afterward, so every list is valid indefinitely (ttlMsreflects a 24-hour horizon rather than an unbounded value, andcacheScopeis always"public", since nothing returned varies per caller).- Streamable HTTP request headers —
MCP-Protocol-Version,Mcp-Method, and (fortools/call,resources/read,prompts/get)Mcp-Name, including the Base64 sentinel encoding for values that are not safe plain ASCII. A mismatch between a header and the request body returnsHeaderMismatch(-32020) and HTTP400, per spec. Header validation only applies to a request that is itself modern; a legacy request is never expected to carry these headers and is not checked for them. extensionscapability field (always an empty object: this server implements no MCP extensions).- Resource-not-found as a protocol error. Independent of the
2026-07-28work, but found while implementing it:resources/readon an unknown URI previously returned a JSON-RPC success whose content happened to describe an error, on both registries, in every revision this server has ever shipped. This is now a real JSON-RPC error —-32002for a legacy request (the code every revision through2025-11-25defines for exactly this case) or-32602for a modern request (the code2026-07-28renumbered it to, to align with the JSON-RPC specification). Seemcp.ErrResourceNotFound,resourceNotFoundCode, andContextAwareRegistry.Readininternal/resources/. 404alongside-32601for a modern request naming a method this server doesn't implement, on HTTP. The transport spec pairs the two specifically so a client can tell a modern server that simply doesn't implement a method apart from a legacy HTTP+SSE server that doesn't host this endpoint at all -- both could otherwise return a bare404with nothing to distinguish them. This is a handler-level rejection like any other-32601, so it is checked independently of the preflight-vs-handler distinction the general400handling below relies on.initializeitself is one instance of this: it isn't a method the modern era recognizes at all (2026-07-28removed the handshake entirely), so a modern-shaped request naming it gets this same treatment rather than a legacyInitializeResult.
What was deliberately not adopted, and why
subscriptions/listen(the replacement for the old GET/SSE stream andresources/subscribe/unsubscribe) — this server has no source of change notifications to deliver. Every tool, resource, and prompt is registered once at startup (built-ins are fixed in code; custom ones load from a definitions file read once during initialization) and never changes for the life of the process, so there is nothing alistChangednotification would ever report. Implementing a long-lived SSE endpoint with keep-alives and disconnect handling for a stream that can never emit anything would be speculative infrastructure with no current use. This server does not declarelistChanged/subscribein its capabilities today either, legacy or modern, so no client should expect it.- Multi Round-Trip Requests (MRTR), and by extension Roots,
Sampling, and Elicitation — this server has never implemented any
of the three client-side features MRTR exists to replace (it talks to
LLM providers directly through its own proxy, not through the MCP
sampling capability), so there is nothing to migrate. The spec also
deprecates Roots, Sampling, and Logging outright as of
2026-07-28; adopting them now to immediately migrate them to MRTR would be building a deprecated feature. x-mcp-headertool parameter annotations — explicitly optional for servers per spec ("use ofx-mcp-headeris optional for servers"). It exists to let infrastructure (load balancers, tenant routers) inspect a specific argument without parsing the JSON-RPC body; none of this server's tools have a parameter that benefits from that, since none of them are multi-tenant-routed.- Icons — purely cosmetic (visual identifiers for tools/resources in a client UI); no functional effect on protocol compliance one way or the other. Not adopted for this pass; nothing prevents adding it later without touching anything above.
Authorisation
The server has its own OAuth 2.0 authorisation server (internal/oauth,
added in #288), alongside the bearer-token scheme in internal/auth.
It is active when authentication is enabled, the oauth method is not
disabled, and http.auth.oauth.issuer is set; see the
OAuth guide. The OAuth changes in
2026-07-28 therefore apply to it, and to the CLI and web client,
which both sign in through it. Most of them bind the client rather
than the server. Their status is as follows:
- Issuer in the authorisation response (SEP-2468). An
authorisation server SHOULD include the RFC 9207
issparameter in its authorisation response, and a client MUST check a presentissagainst the issuer it recorded before redeeming the code. This server does not sendiss, and neither the CLI nor the web client checks it. See "Known gaps" below. - Client ID Metadata Documents, and the deprecation of Dynamic
Client Registration. The spec deprecates RFC 7591 Dynamic Client
Registration in favour of Client ID Metadata Documents, which both
clients and authorisation servers SHOULD support; registration
remains for authorisation servers that lack them. This server
supports only Dynamic Client Registration (
/oauth/register, enabled by default and controlled byallow_dynamic_registration), and does not advertiseclient_id_metadata_document_supported, so a conforming client falls back to registering. There is no static (pre-registered) client configuration yet either. See "Known gaps" below. application_type(SEP-837). A client MUST send anapplication_typewhen it registers, because an OpenID Connect authorisation server defaults a missing one to"web"and may then reject native-style redirect URIs. This server is not an OpenID Connect provider and ignores the field. The CLI and web client do not send it. See "Known gaps" below.- Credentials bound to their authorisation server (SEP-2352). A
client MUST key persisted credentials by the issuer, and MUST NOT
reuse them with another authorisation server. The CLI refuses
metadata whose issuer does not have the origin of the server it was
pointed at, and keys its cached client ID and tokens by that
origin, so its credentials cannot follow a changed issuer to another
origin; see
validateMetadataandcacheKeyininternal/chat/oauth_client.go. The key omits the issuer's path, though, so the CLI meets this only in part. The web client also meets it only in part: it caches its registered client ID with the issuer it came from, and registers again when the issuer changes (seeensureClientinweb/src/lib/oauth.js), but it keeps its tokens under one fixed key with no issuer. See "Known gaps" below.
Known gaps
Unlike the section above, these are not deliberate choices -- they are gaps found during review that this pass did not set out to fix, recorded here so nobody has to re-derive them:
Acceptheader. The transport requires a client to list bothapplication/jsonandtext/event-stream, because a conforming server may answer any request with either a single JSON object or a request-scoped SSE stream. The requirement binds the client rather than the server, and this server always answers withapplication/json, since it implements no streaming method; it therefore does not inspect the header, and a request that omits it is not rejected. The OpenAPI specification documents the header so the contract stays discoverable. Enforcing it would only become meaningful alongside a streaming response.- JSON-RPC batches under
2025-03-26. That revision requires an implementation to accept a JSON-RPC batch (an array of requests), although2025-06-18removed batching again. This server decodes each message as a single request on both transports, so a batch gets-32700 Parse error, even from a client that negotiated2025-03-26. - No
issin the OAuth authorisation response. The authorisation server does not send the RFC 9207issparameter that SEP-2468 says it SHOULD, and the CLI and web client do not check a present one, as that SEP says they MUST. The CLI rejects metadata whose issuer or endpoints are not on its server's origin, and the web client fetches metadata and sends token traffic only to same-origin paths, so both redeem codes only with this server's own authorisation server, which never sendsiss. Nothing is lost today; the check matters once a client can be sent to a different authorisation server. - No Client ID Metadata Documents. The authorisation server
cannot fetch a client's metadata document from a URL-formatted
client_id, so it supports only the deprecated Dynamic Client Registration. Without static client configuration either, turningallow_dynamic_registrationoff leaves a client no way to obtain a client ID at all. - No
application_typefrom the CLI or web client. Both register without theapplication_typethat SEP-837 requires. This server ignores the field, so nothing fails today; an OpenID Connect authorisation server would treat the CLI's loopback redirect URI as a"web"client's and might reject it. - Web client tokens not keyed by issuer. SEP-2352 requires a
client to keep persisted credentials per authorisation server. The
web client keys only its client ID by issuer; its token session sits
under a single fixed
localStoragekey. Its token traffic only goes to same-origin paths, but a proxy or deployment change behind that origin could put a different authorisation server there, and the stored refresh token would then be sent to it without any issuer comparison. - CLI credentials keyed by origin, not issuer.
validateMetadatacompares origins only, andcacheKeyomits the issuer's path. If the issuer moves from one path to another on the same origin, the CLI reuses its cached client ID and sends its refresh token to the new token endpoint, where SEP-2352 asks for fresh credentials. - Optional additions in the newer legacy revisions. Negotiating
2025-03-26,2025-06-18or2025-11-25does not mean the server implements every optional feature those revisions add; for example, icons are listed above as not adopted.
Where the implementation lives
internal/mcp/modern.go— the modern-protocol machinery:_metaparsing and era detection,server/discover's data, the two new error types, HTTP header validation, and the result-wrapping helpers used by both transports.internal/mcp/server.go/internal/mcp/http_server.go— the per-request modern/legacy branch,server/discover's dispatch, and every handler updated to send a wrapped result for a modern request.internal/resources/registry.go/internal/resources/context_aware_registry.go— the resource-lookup ordering fix (checking whether a URI is known before acquiring a database client, so a not-found response never depends on database connectivity) and theErrResourceNotFoundsentinel.test/mcp_modern_protocol_test.go— end-to-end coverage against the real server binary over real HTTP:server/discover's shape, header validation (missing headers, mismatchedMcp-Name, unsupported version, missingclientCapabilities), the modern result fields, and confirmation that a legacy request sees none of them.