Liking cljdoc? Tell your friends :D

MCP 2026-07-28 wire reference

This is a working reference for implementing MCP revision 2026-07-28 (the stateless revision) in presa. No copy of the specification prose was available when it was written. Every fact below is taken from one of these sources and cites it:

TagSource
v26snodo lib/snodo/protocol/v2026_07_28.ex
snodo:<path>another file under the snodo checkout (~/Code/github.com/joshrotenberg/snodo/)
schemasnodo:interop/schema_validation/schema.json, the official 2026-07-28 JSON schema pinned at modelcontextprotocol commit 5f5440bb (provenance.json). Cited as schema $defs.Name.
runnerthe official conformance runner @modelcontextprotocol/conformance@0.2.0-alpha.12, snodo:conformance/node_modules/@modelcontextprotocol/conformance/dist/index.js. Cited by scenario name, or by the minified helper name (yt, bt, wt, Tt, It, Ft, Ac) where the fact lives in shared code.
fixturesnodo:conformance/support/*.ex and snodo:conformance/fixture_server.exs

The schema is the strongest source for shapes; the runner is the strongest source for what is scored; snodo is the strongest source for behavior the schema leaves open. Where they differ, the section says so.

Spec page URLs seen in the sources (not fetched): the runner cites https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr, .../draft/server/utilities/caching, .../draft/basic/transports; snodo cites https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr (snodo:guides/interactive-operations.md:9).

1. JSON-RPC envelope

  • Every message is a JSON-RPC 2.0 object with "jsonrpc": "2.0". A missing or different value is -32600 Expected a JSON-RPC 2.0 request (snodo:lib/snodo/envelope.ex:68-72).
  • method must be a string (-32600), params if present must be an object (-32602 Request params must be an object) (envelope.ex:74-80).
  • Request id is a string of at most 256 bytes or an integer in the int64 range; anything else is -32600 and the error carries "id": null (envelope.ex:82-105, snodo:guides/transports.md:26-31). The schema type is ["string","integer"] (schema $defs.RequestId).
  • Batching is forbidden in this revision (v26:88 batching: :forbidden). A JSON array is not a map, so it is rejected as -32600 Invalid Request (envelope.ex:43).
  • A message with a string method and no id is a notification and never gets a reply, even when malformed (snodo:lib/snodo/server.ex:413-417). A posted response object (no method, has result or error) is also not answered (envelope.ex:50-53).
  • The server MUST NOT send JSON-RPC requests to the client in this revision. The runner's stateless client throws -32600 Server sent request '<m>' on response stream; stateless lifecycle forbids this (use MRTR) if a frame with both method and id arrives (runner Tt). Server-to-client requests are replaced by input requests embedded in results (section 10).

2. Request _meta

Every request's params carries _meta (schema $defs.RequestParams requires _meta; $defs.RequestMetaObject):

KeyRequiredValue
io.modelcontextprotocol/protocolVersionyesstring, e.g. "2026-07-28"
io.modelcontextprotocol/clientCapabilitiesyesClientCapabilities object; {} means none
io.modelcontextprotocol/clientInfono (SHOULD)Implementation: name, version required; title, description, icons, websiteUrl optional
io.modelcontextprotocol/logLevelnoone of debug info notice warning error critical alert emergency
progressTokennostring or integer

Key constants: v26:28-33. Required-ness: schema $defs.RequestMetaObject required, and snodo's profile request_metadata: %{request: :required, notification: :optional} (v26:89).

Example request:

{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "tools/call",
  "params": {
    "name": "test_simple_text",
    "arguments": {},
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {"elicitation": {}},
      "io.modelcontextprotocol/clientInfo": {"name": "presa", "version": "0.1.0"},
      "io.modelcontextprotocol/logLevel": "info",
      "progressToken": "p-1"
    }
  }
}

Server validation order in snodo for a request (v26:303-307, v26:1034-1042):

  1. _meta absent is treated as {}; _meta not an object is -32602 _meta must be an object (v26:1024-1030).
  2. Every _meta key must match the key syntax below and every value must be JSON, else -32602 _meta contains an invalid key or non-JSON value (v26:1176-1190).
  3. progressToken, if present, must be a bounded id, else -32602 progressToken must be a string of at most 256 bytes or an int64 integer (v26:1192-1206).
  4. logLevel, if present, must be a known level, else -32602 Invalid MCP request log level (v26:1208-1214).
  5. protocolVersion missing is -32602 Missing required io.modelcontextprotocol/protocolVersion metadata; not a string is -32602 io.modelcontextprotocol/protocolVersion must be a string (v26:1118-1124).
  6. On HTTP, an MCP-Protocol-Version header that differs from the body value is -32020 (section 13.4) (v26:1044-1061).
  7. A version other than 2026-07-28 is -32022 with data.requested and data.supported (v26:1063-1076).
  8. clientInfo, if present, must have string name and version (-32602 Client info must include string name and version), optional string title/description, URI websiteUrl, and a valid icons list (v26:1078-1090, v26:1216-1250).
  9. clientCapabilities missing is -32602 Missing required client capabilities metadata; not an object is -32602 Client capabilities must be an object (v26:1099-1105). Sub-shapes checked: roots, sampling (with context, tools objects), elicitation (with form, url objects; a non-empty elicitation must contain form or url), experimental (string keys to objects), extensions (keys must be prefixed meta keys, values objects) (v26:1107-1116, v26:1273-1346).

For notifications, _meta is optional and only checked for shape; clientCapabilities defaults to {} (v26:1032, v26:1092-1097).

The runner's server-stateless scenario requires: params: {} (no _meta), _meta without protocolVersion, and _meta without clientCapabilities are each rejected with -32602 and HTTP 400; _meta without clientInfo is served with HTTP 200 (runner server-stateless, checks sep-2575-request-meta-invalid-*, sep-2575-http-server-meta-invalid-400, sep-2575-request-meta-client-info-optional). The runner's own mock server checks in this order: missing MCP-Protocol-Version header (400, -32020), missing required _meta keys (400, -32602 Invalid params: missing _meta keys: ...), header not equal to _meta version (400, -32020), unsupported version (400, -32022) (runner It).

Capabilities are per request. The schema says servers MUST NOT infer capabilities from prior requests (schema $defs.RequestMetaObject clientCapabilities description).

2.1 Meta key syntax

From schema $defs.MetaObject and the snodo regex at v26:42:

  • A key is an optional prefix followed by a name.
  • Prefix: one or more dot-separated labels then /. Each label starts with a letter, ends with a letter or digit, and has letters, digits, or - inside.
  • Name: empty, or starts and ends with [A-Za-z0-9] with [A-Za-z0-9._-] inside.
  • A prefix whose second label is modelcontextprotocol or mcp is reserved (io.modelcontextprotocol/, dev.mcp/). com.example.mcp/ is not reserved.

snodo regex, verbatim:

^(?:(?:[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?)(?:\.(?:[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?))*\/)?(?:[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)?$

extensions capability keys must match this and contain / (v26:1321-1346; schema $defs.ServerCapabilities.extensions "with a mandatory prefix").

3. Results: resultType and result _meta

  • Every result has resultType (schema $defs.Result requires it). Values: "complete" or "input_required" (schema $defs.ResultType). A client receiving a result without resultType (an older server) MUST treat it as "complete" (schema $defs.Result description; runner sep-2322-client-request-state check sep-2322-default-result-type-complete). Extensions add others (Tasks uses "task", runner tasks-mrtr-composition).
  • _meta on a result may carry io.modelcontextprotocol/serverInfo, an Implementation (SHOULD on every response, schema $defs.ResultMetaObject). snodo stamps it on every result, merging any handler _meta (v26:1507-1525). The runner checks it on the discover result and issues a WARNING if it is missing or only present as a top-level serverInfo (runner server-stateless, check sep-2575-server-identifies-in-result-meta).
{"jsonrpc": "2.0", "id": 7, "result": {
  "resultType": "complete",
  "content": [{"type": "text", "text": "This is a simple text response for testing."}],
  "isError": false,
  "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "snodo-conformance", "version": "0.1.0"}}
}}

Every message an implementation sends during a scored scenario is validated against the 2026-07-28 schema, producing a wire-schema-valid check (runner Qe/rt). A missing ttlMs or resultType therefore fails that check even where the scenario's own assertion would pass.

4. server/discover

Request: params with only _meta (schema $defs.DiscoverRequest, $defs.RequestParams). snodo accepts any params map (v26:808).

Result (schema $defs.DiscoverResult; required: resultType, supportedVersions, capabilities, ttlMs, cacheScope; optional instructions, _meta). snodo builds it at v26:752-773 and snodo:lib/snodo/server.ex:669-675:

{
  "resultType": "complete",
  "supportedVersions": ["2026-07-28"],
  "capabilities": {
    "tools": {"listChanged": true},
    "prompts": {"listChanged": true},
    "resources": {},
    "completions": {},
    "logging": {},
    "extensions": {"io.modelcontextprotocol/tasks": {}}
  },
  "ttlMs": 0,
  "cacheScope": "private",
  "instructions": "optional natural-language guidance",
  "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "snodo-conformance", "version": "0.1.0"}}
}

That capabilities object is what the snodo fixture advertises (fixture:fixture.ex:396-419, fixture:tasks.ex:203-208).

ServerCapabilities (schema $defs.ServerCapabilities):

KeyShape
tools{"listChanged"?: bool}
resources{"listChanged"?: bool, "subscribe"?: bool}
prompts{"listChanged"?: bool}
completionsJSON object ({})
loggingJSON object ({})
experimentalmap of name to object
extensionsmap of prefixed id to settings object; {} means supported with no settings

There is no subscriptions capability key in the schema. Support for subscriptions/listen is implied by listChanged or resources.subscribe. The runner treats a server that advertises none of tools.listChanged, prompts.listChanged, resources.listChanged, resources.subscribe and answers subscriptions/listen with -32601 as not applicable (SKIPPED), and one that advertises any of them and answers -32601 as a failure (runner server-stateless, helper ce). snodo answers -32601 Method not found: subscriptions/listen when no subscription source is configured (snodo:lib/snodo/server.ex:677-685) and refuses to advertise listChanged/subscribe without one (snodo:guides/subscriptions.md).

supportedVersions lists every enabled version; snodo uses the registry list (v26:767). The -32022 error's data.supported must be a subset of it (runner server-stateless, sep-2575-server-unsupported-version-error).

Capabilities must match handlers: if tools is advertised tools/list must return a tools array; if not, tools/list must give -32601. Same idea for prompts (runner server-stateless, checks sep-2575-discover-capabilities-match-handlers, sep-2575-server-declares-prompts-in-discover). snodo returns -32601 Method not found: <method> for any method whose capability is absent (v26:1126-1132).

5. Methods

All results below also carry resultType and may carry _meta. Method inventory of the profile: v26:90-245. Methods whose capability is not advertised return -32601 (v26:428-490).

5.1 tools/list

Params: optional cursor string (schema $defs.PaginatedRequestParams); a non-string cursor is -32602 Cursor must be a string (v26:1134-1145).

Result (schema $defs.ListToolsResult, required tools, ttlMs, cacheScope, resultType; optional nextCursor):

{"resultType": "complete",
 "tools": [{"name": "test_simple_text",
            "description": "Returns one text content item for the official conformance runner",
            "inputSchema": {"type": "object"}}],
 "ttlMs": 0, "cacheScope": "private"}

Tool (schema $defs.Tool): required name, inputSchema (object with "type": "object", any JSON Schema 2020-12 keywords, optional $schema); optional title, description, outputSchema (any 2020-12 schema, not restricted to objects), annotations (title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint), icons, _meta. snodo omits nil fields, empty annotations, empty icons, empty _meta (v26:1445-1463) and sorts tools by name (snodo:lib/snodo/router.ex:83-90).

Runner tools-list requires every tool to have name, description and inputSchema; tools-name-format (WARNING only) wants names of 1 to 128 characters matching ^[A-Za-z0-9_.-]+$; tools-list-deterministic-order (WARNING only) calls tools/list three times and expects identical order.

Inside inputSchema, a property may carry "x-mcp-header": "<Name>" to mirror that argument into an HTTP header (section 13.6) (schema $defs.Tool.inputSchema).

5.2 tools/call

Params (schema $defs.CallToolRequestParams): name (required), arguments (object), inputResponses, requestState, _meta.

  • name missing or not a non-empty string: -32602 tools/call requires a non-empty string name (v26:916-921).
  • arguments not an object: -32602 (v26:938-944, v26:1154-1160).
  • Unknown tool: -32602 Unknown tool: <name> (snodo:lib/snodo/router.ex:549).
  • Arguments that fail the input schema or miss required keys are a tool execution error (isError: true), not a protocol error (router.ex:353-355, comment cites SEP-1303).

Result (schema $defs.CallToolResult; required content, resultType; optional structuredContent (any JSON value), isError (default false)):

{"resultType": "complete",
 "content": [{"type": "text", "text": "{\"answer\":42}"}],
 "structuredContent": {"answer": 42},
 "isError": false}

snodo shapes: text result to one text block; structured result to a text block holding the JSON encoding plus structuredContent; error result to one text block with isError: true; raw maps get content: [] and isError: false defaults (v26:1419-1443). tools/call results carry no ttlMs/cacheScope (v26:614-619; schema $defs.CallToolResult has neither). When the tool declares outputSchema, snodo validates the result against it (router.ex validate_output).

Tool errors belong in the result with isError: true; failing to find the tool or exceptional conditions are JSON-RPC errors (schema $defs.CallToolResult.isError description).

Content blocks (schema $defs.ContentBlock):

typeRequired fieldsOptional
texttextannotations, _meta
imagedata (base64), mimeTypeannotations, _meta
audiodata (base64), mimeTypeannotations, _meta
resource_linkuri, nametitle, description, mimeType, size, icons, annotations, _meta
resourceresource: a TextResourceContents or BlobResourceContentsannotations, _meta

Annotations: audience (array of "user"/"assistant"), priority (0 to 1), lastModified string (schema $defs.Annotations).

5.3 resources/list and resources/templates/list

Params: optional cursor. Results (schema $defs.ListResourcesResult, $defs.ListResourceTemplatesResult; required list key, ttlMs, cacheScope, resultType):

{"resultType": "complete",
 "resources": [{"uri": "test://static-binary", "name": "static_binary",
                "description": "Static binary resource required by the frozen conformance runner",
                "mimeType": "application/octet-stream"}],
 "ttlMs": 0, "cacheScope": "private"}
{"resultType": "complete",
 "resourceTemplates": [{"uriTemplate": "test://template/{id}/data", "name": "template_data",
                        "mimeType": "text/plain"}],
 "ttlMs": 0, "cacheScope": "private"}

Resource: required uri, name; optional title, description, mimeType, size, icons, annotations, _meta. ResourceTemplate: required uriTemplate, name; optional title, description, mimeType, icons, annotations, _meta (schema). snodo sorts resources by URI and templates by template string (router.ex:157-176). Runner resources-list checks each resource has uri and name only.

5.4 resources/read

Params (schema $defs.ReadResourceRequestParams): uri (required), inputResponses, requestState, _meta. snodo: missing or empty uri is -32602 resources/read requires a non-empty string uri; a URI without a scheme is -32602 resources/read uri must be an absolute URI (v26:853-863). The code is what matters; the message can also come from operation resolution (resources/read requires an absolute URI, v26:392-394).

Result (schema $defs.ReadResourceResult; required contents, ttlMs, cacheScope, resultType):

{"resultType": "complete",
 "contents": [{"uri": "test://static-text", "mimeType": "text/plain",
               "text": "This is a static text resource for testing."}],
 "ttlMs": 0, "cacheScope": "private"}

Contents items are TextResourceContents (uri, text, optional mimeType, _meta) or BlobResourceContents (uri, blob base64, optional mimeType, _meta). Runner resources-read-text requires uri, mimeType, text on the first item; resources-read-binary requires uri, mimeType, blob; resources-templates-read reads test://template/123/data and requires the text to contain 123.

Resource not found (SEP-2164, runner sep-2164-resource-not-found): the server MUST NOT return an empty contents array (FAILURE), SHOULD return -32602 (WARNING otherwise; the runner notes -32002 was the old code), and SHOULD put the requested URI in error.data.uri (WARNING otherwise; FAILURE if no JSON-RPC error at all). snodo (router.ex:553):

{"jsonrpc": "2.0", "id": 2, "error": {"code": -32602, "message": "Resource not found",
  "data": {"uri": "test://nonexistent-resource-for-conformance-testing"}}}

After an MRTR round (request carried requestState or non-empty inputResponses), snodo forces ttlMs: 0 on the read result (snodo:lib/snodo/server.ex:799-805). This is snodo policy, not a schema rule.

5.5 prompts/list and prompts/get

prompts/list: optional cursor; result prompts, ttlMs, cacheScope (schema $defs.ListPromptsResult). Prompt: required name; optional title, description, arguments (each name required, optional title, description, required bool), icons, _meta. Runner prompts-list requires name and description on every prompt.

prompts/get params (schema $defs.GetPromptRequestParams): name (required), arguments (object of string to string), inputResponses, requestState, _meta. snodo errors: -32602 prompts/get requires a non-empty string name, -32602 prompts/get arguments must map strings to strings (v26:923-963), -32602 Unknown prompt: <name> (router.ex:550), -32602 Missing required prompt arguments with data.missing (router.ex:887).

Result (schema $defs.GetPromptResult; required messages, resultType; optional description). No ttlMs. PromptMessage is role ("user"/"assistant") plus one ContentBlock as content (not an array):

{"resultType": "complete", "description": "Embedded resource conformance prompt",
 "messages": [
   {"role": "user", "content": {"type": "resource",
     "resource": {"uri": "test://example-resource", "mimeType": "text/plain",
                  "text": "This is embedded resource content."}}},
   {"role": "user", "content": {"type": "image",
     "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Z4p8AAAAASUVORK5CYII=",
     "mimeType": "image/png"}}
 ]}

5.6 completion/complete

Params (schema $defs.CompleteRequestParams): ref (required), argument (required: name, value strings), context (optional: arguments object of string to string), _meta.

  • ref is {"type": "ref/prompt", "name": "...", "title"?: "..."} or {"type": "ref/resource", "uri": "<uri template>"} (schema $defs.PromptReference, $defs.ResourceTemplateReference).
  • snodo errors, all -32602: Completion reference type is not supported, Completion requires a prompt or resource-template reference, Completion argument requires string name and value, Completion context must be an object, Completion context arguments must map strings to strings (snodo:lib/snodo/completion.ex:60-125); Unknown completion prompt: <name>, Unknown completion resource template with data.uri, Argument does not support completion (router.ex:556-602).

Result (schema $defs.CompleteResult): completion.values (array of strings, maxItems: 100, required), optional completion.total (integer), completion.hasMore (bool). snodo also enforces total >= len(values) (completion.ex:127-149, shaping v26:515-530):

{"resultType": "complete", "completion": {"values": ["test"], "total": 1, "hasMore": false}}

Runner completion-complete sends ref/prompt test_prompt_with_arguments, argument {"name": "arg1", "value": "test"}, and checks only that completion.values is an array.

5.7 Removed methods

initialize, ping, logging/setLevel, resources/subscribe, resources/unsubscribe do not exist in 2026-07-28 (none appear in the schema). A request for any of them, or for an unknown method, MUST get -32601 and, on HTTP, status 404, with the request id preserved (runner server-stateless, checks sep-2575-http-server-method-not-found-404-*, sep-2575-http-server-error-jsonrpc-id). logLevel in _meta replaces logging/setLevel; subscriptions/listen replaces resources/subscribe (schema $defs.RequestMetaObject, $defs.SubscriptionFilter). snodo message: Method not found: <method> (snodo:lib/snodo/error.ex:37-39).

6. Pagination

  • Applies to tools/list, prompts/list, resources/list, resources/templates/list (snodo:lib/snodo/pagination.ex:20-21).
  • Request cursor is an opaque string; result nextCursor is present only when more items may follow (schema $defs.PaginatedResult). Absent nextCursor means last page.
  • snodo cursor format (implementation choice, opaque to clients): "mcp1." + base64url(JSON [1, protocolVersion, method, offset, catalogHash]), max 2048 bytes, default page size 100 (pagination.ex:15-18, 135-147).
  • Errors (both -32602): Invalid pagination cursor for an undecodable, oversize, wrong-method, or wrong-version cursor; Pagination cursor has expired when the catalog changed or the offset is past the end (pagination.ex:97-133, 154-155). Non-string cursor: -32602 Cursor must be a string.
  • No runner scenario in the 2026-07-28 required set exercises pagination.

7. Caching (SEP-2549)

  • ttlMs (integer >= 0) and cacheScope ("public" or "private") are required on server/discover, tools/list, prompts/list, resources/list, resources/templates/list, resources/read (schema $defs.CacheableResult and the six result definitions; the runner's default filler Ft fills exactly these six).
  • ttlMs: 0 means immediately stale; positive means fresh for that many ms. public may be shared across authorization contexts; private only within one (schema $defs.CacheableResult descriptions).
  • snodo defaults every policy to ttl_ms: 0, scope: "private" (snodo:lib/snodo/server/runtime.ex:53-56).

Runner caching checks (all FAILURE on miss): tools/list, prompts/list, resources/list, resources/templates/list each include both fields; then resources/read of the first URI in the resources/list result includes both (SKIPPED if the list is empty); every ttlMs is a non-negative integer; every cacheScope is public or private. The first listed resource must be readable without input, which is why snodo names its MRTR resource z-mrtr://... (fixture:mrtr.ex:490-497). server/discover is not checked by this scenario but its schema requires the fields.

8. Logging and progress

8.1 Logging

  • Server advertises logging: {}. A request opts in with _meta["io.modelcontextprotocol/logLevel"]. Without it the server MUST NOT send notifications/message for that request (schema $defs.RequestMetaObject logLevel description; runner server-stateless check sep-2575-server-no-log-without-loglevel, which calls test_logging_tool without a level and fails on any notifications/message frame). snodo drops messages below the level (snodo:lib/snodo/logging.ex:151-157).
  • Notification (schema $defs.LoggingMessageNotificationParams: required level, data (any JSON); optional logger, _meta):
{"jsonrpc": "2.0", "method": "notifications/message",
 "params": {"level": "info", "logger": "conformance", "data": "info message"}}
  • The notification carries no request id. On HTTP it is delivered on the SSE response of the request that produced it; on stdio it is just a line on stdout, so a client with several requests in flight cannot attribute it (snodo:guides/logging.md:86-92). snodo marks notifications/message lifecycle :deprecated in its profile (v26:197-205); the source of that deprecation is not given.

8.2 Progress

  • A request opts in with _meta.progressToken (string or integer). The server is not obliged to send progress (schema $defs.RequestMetaObject).
  • Notification (schema $defs.ProgressNotificationParams: required progressToken, progress (number); optional total (number), message (string), _meta) (v26:639-645, validation v26:897-914):
{"jsonrpc": "2.0", "method": "notifications/progress",
 "params": {"progressToken": "progress-test-1", "progress": 50, "total": 100,
            "message": "First half complete"}}
  • snodo requires strictly increasing progress values and caps updates at 1000 per request and messages at 4 KiB (snodo:lib/snodo/progress.ex:1-18).
  • Runner tools-call-with-progress calls test_tool_with_progress with _meta.progressToken: "progress-test-1" and requires at least 3 notifications for that token, non-decreasing.
  • Progress and log notifications must precede the final response for the request (snodo:lib/snodo/logging.ex:28-30).
  • subscriptions/listen gets no progress binding (v26:329-331).

8.3 Transport delivery

  • HTTP: the response starts as JSON. When the first progress or log notification is produced, snodo writes 200 with content-type: text/event-stream, cache-control: no-cache, x-accel-buffering: no, then each notification as data: <json>\r\n\r\n, then the final JSON-RPC response as the last data: event, then closes (snodo:lib/snodo/transport/streamable_http/server.ex:601-641). Without a token, a level, or any report, the response stays plain JSON. Once SSE has started, a later error is a JSON-RPC error event, not an HTTP status (snodo:integrations/plug/doc/readme.md, "HTTP status is already 200"). The runner's stateless client reads data: lines from an event stream until the event whose id matches the request (runner Ct).
  • stdio: notifications are written as separate lines before the response (snodo:guides/transports.md:44-48).

9. Cancellation

Notification (schema $defs.CancelledNotificationParams: required requestId; optional reason string, _meta):

{"jsonrpc": "2.0", "method": "notifications/cancelled",
 "params": {"requestId": 7, "reason": "User cancelled"}}
  • Direction: snodo implements client to server only; server to client is marked unsupported (v26:44-46, 181-188).
  • snodo validation: requestId must be a bounded id, reason a string (v26:965-983). Invalid ones are ignored, as notifications are never answered.
  • stdio: stops the in-flight request; no late response is written (snodo:guides/transports.md:44-48). A subscriptions/listen on stdio is closed the same way.
  • HTTP: closing the connection cancels the request; snodo's native listener cancels on socket close (snodo:lib/snodo/client/http.ex:36-38, snodo:guides/transports.md:117). A posted notifications/cancelled gets 202; snodo's Plug adapter only matches it to another request when a trusted cancellation scope is configured, so anonymous callers cannot cancel each other by guessing ids (snodo:integrations/plug/doc/readme.md). A cancelled HTTP request is answered 204 with no body if the socket is still writable (streamable_http/server.ex:691). This HTTP behavior is snodo design; I found no schema or runner rule that mandates it.
  • No scored 2026-07-28 server scenario tests cancellation.

10. Multi round-trip requests (SEP-2322, "MRTR")

Only tools/call, resources/read, prompts/get may return input_required (snodo:lib/snodo/mrtr.ex:30; the runner also lists tasks/result, runner input-required-result-unsupported-methods description). Runner checks tools/list and prompts/list never return it. snodo turns an input_required result from any other operation into -32603 Input-required result is not permitted for this operation (mrtr.ex:90-92).

10.1 Round 1: the input_required result

schema $defs.InputRequiredResult: resultType: "input_required", optional inputRequests, optional requestState (string), _meta. At least one of inputRequests or requestState MUST be present (schema description; snodo mrtr.ex:94-105, else -32603 Invalid input-required result).

inputRequests maps server-chosen ids to bare requests: {"method", "params"} with no jsonrpc or id (schema $defs.InputRequests, $defs.InputRequest = CreateMessageRequest | ListRootsRequest | ElicitRequest).

{"jsonrpc": "2.0", "id": 1, "result": {
  "resultType": "input_required",
  "inputRequests": {
    "user_name": {"method": "elicitation/create", "params": {
      "mode": "form",
      "message": "Please provide name",
      "requestedSchema": {"type": "object", "properties": {"name": {"type": "string"}},
                          "required": ["name"]}}},
    "greeting": {"method": "sampling/createMessage", "params": {
      "messages": [{"role": "user", "content": {"type": "text", "text": "Generate a greeting"}}],
      "maxTokens": 50}},
    "client_roots": {"method": "roots/list", "params": {}}
  },
  "requestState": "mrtr1.<base64url-payload>.<base64url-hmac>",
  "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "snodo-conformance", "version": "0.1.0"}}
}}

Request kinds:

  • elicitation/create (schema $defs.ElicitRequestFormParams, $defs.ElicitRequestURLParams). Form mode: message, requestedSchema required, mode: "form" optional (default). requestedSchema is {"type": "object", "properties": {...}, "required"?: [...], "$schema"?} with flat primitive properties only: string (minLength, maxLength, format in email uri date date-time), number/integer (minimum, maximum), boolean, string enums (enum, optional legacy enumNames, or oneOf of {const, title}), and multi-select arrays (items with string enum or anyOf of {const, title}, minItems, maxItems); each with optional title, description, default (snodo:lib/snodo/elicitation.ex:108-187). URL mode: mode: "url", message, url all required; snodo requires an http(s) URL with a host and no userinfo (elicitation.ex:258-267).
  • sampling/createMessage (deprecated by SEP-2577 but scored): params messages (non-empty, role user/assistant), maxTokens (positive integer) required; optional systemPrompt, modelPreferences (hints, priorities 0 to 1), temperature, stopSequences, includeContext (none thisServer allServers), metadata, tools, toolChoice ({"mode": "auto"|"none"|"required"}) (snodo:lib/snodo/sampling.ex:34-53, schema $defs.CreateMessageRequestParams).
  • roots/list (deprecated by SEP-2577 but scored): params optional, may contain only _meta (snodo:lib/snodo/roots.ex:31-42).

10.2 Capability check (-32021)

Before sending an input_required result, the server checks each input request against this request's clientCapabilities (mrtr.ex:126-150):

RequestNeeds
elicitation/create formelicitation present; {} counts as form support, otherwise elicitation.form (elicitation.ex:280-281)
elicitation/create urlelicitation.url
sampling/createMessagesampling; plus sampling.tools if tools or toolChoice is set; plus sampling.context if includeContext is not "none" (sampling.ex:120-132)
roots/listroots (roots.ex:48-57)

If any is unsupported, the whole request fails (schema $defs.MissingRequiredClientCapabilityError; HTTP 400):

{"jsonrpc": "2.0", "id": 401, "error": {"code": -32021,
  "message": "Missing required client capability",
  "data": {"requiredCapabilities": {"sampling": {}}}}}

requiredCapabilities is a ClientCapabilities object (merged across all unsupported requests, e.g. {"elicitation": {"url": {}}, "sampling": {"tools": {}}}), not an array of names (runner server-stateless check sep-2575-server-rejects-undeclared-capability).

Two different scenarios cover this. server-stateless calls test_missing_capability with clientCapabilities: {} and expects -32021 naming sampling plus HTTP 400. input-required-result-capability-check calls test_input_required_result_capabilities with clientCapabilities: {"sampling": {}} and expects an input_required result containing no elicitation/create request; a complete result there is a FAILURE, an empty inputRequests is a FAILURE marked untestable. So a tool that wants to succeed should filter its requests by declared capabilities, and the -32021 path is the server's guard for tools that do not.

10.3 Round 2: the retry

The client re-sends the same method and params with a new JSON-RPC id, plus inputResponses (map from the same ids to bare results) and, if the server sent one, requestState echoed byte for byte (schema $defs.InputResponseRequestParams; runner sep-2322-client-request-state).

{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {
  "name": "test_input_required_result_multiple_inputs", "arguments": {},
  "inputResponses": {
    "user_name": {"action": "accept", "content": {"name": "Alice"}},
    "greeting": {"role": "assistant", "content": {"type": "text", "text": "Hello there!"},
                 "model": "test-model", "stopReason": "endTurn"},
    "client_roots": {"roots": [{"uri": "file:///test/root", "name": "Test Root"}]}
  },
  "requestState": "mrtr1.<same token>",
  "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
            "io.modelcontextprotocol/clientCapabilities": {"sampling": {}, "elicitation": {}, "roots": {"listChanged": true}}}
}}

Response shapes (schema $defs.InputResponse = CreateMessageResult | ListRootsResult | ElicitResult):

  • ElicitResult: action in accept decline cancel (required); content only for accept in form mode, a flat map of string, integer, boolean, or string array (schema $defs.ElicitResult). For URL mode, accept means consent to navigate, not completion (elicitation.ex:19-23).
  • CreateMessageResult: role, content (one block or a list), model required; stopReason optional (sampling.ex:168-172).
  • ListRootsResult: roots list of {"uri": "file://...", "name"?} (roots.ex:91-100).

Server rules on retry:

  • Validate structure: inputResponses must be an object whose values are objects, else -32602 inputResponses must map string IDs to bare result objects or inputResponses must be an object; requestState must be a string, else -32602 requestState must be a string (mrtr.ex:39-64). This runs at admission for all three MRTR methods (v26:829-851, 881-887).
  • Ignore ids it did not ask for (runner input-required-result-ignore-extra-params).
  • Re-request missing answers with a new input_required, not an error (runner input-required-result-missing-input-response, WARNING otherwise).
  • Each round is a fresh, independent request; nothing waits on the server (mrtr.ex:5-9).

10.4 requestState integrity

The server must reject a tampered requestState with a JSON-RPC error (runner input-required-result-tampered-state appends -TAMPERED to the token; "code -32602 or similar"). snodo's helper (snodo:lib/snodo/mrtr/state.ex):

  • Token: mrtr1.<b64url(json)>.<b64url(hmac)> (single key) or mrtr2.<keyId>.<b64url(json)>.<b64url(hmac)> (keyring) (state.ex:66-110).
  • Payload JSON [version, issuedAt, expiresAt, scope, data] with unix seconds (state.ex:90-93).
  • scope = HMAC-SHA256(secret, "snodo-mrtr-scope-v1:" + canonicalJSON([method, params minus _meta/requestState/inputResponses, principal])), so a token from one tool or argument set fails on another (state.ex:229-243).
  • Signature HMAC-SHA256(secret, "snodo-mrtr-token-v1:" + encoded) (v2 adds the key id) (state.ex:274-282).
  • TTL default 300 s, max 900 s; token max 16 KiB; secret at least 32 bytes (state.ex:70-75).
  • Any failure is -32602 Invalid or expired request state with no data (state.ex:121-134).
  • Tokens are signed, not encrypted, and replayable until expiry (state.ex:54-59).

The format is snodo's choice; only integrity and opacity are required. Note the multi-round scenario requires the round-2 requestState to differ from round 1 (runner input-required-result-multi-round, check -r2).

11. subscriptions/listen

Request (schema $defs.SubscriptionsListenRequestParams: notifications required, _meta required):

{"jsonrpc": "2.0", "id": 9, "method": "subscriptions/listen", "params": {
  "notifications": {"toolsListChanged": true, "promptsListChanged": true,
                    "resourcesListChanged": false,
                    "resourceSubscriptions": ["test://static-text"]},
  "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
            "io.modelcontextprotocol/clientCapabilities": {}}}}
  • notifications missing or not an object: -32602 subscriptions/listen requires a notifications object; the three flags must be booleans; resourceSubscriptions a list of absolute URI strings, at most 1000 in snodo (v26:866-879, 985-1022, v26:41).
  • Every type is opt-in; the server MUST NOT send unrequested types (schema $defs.SubscriptionFilter).

Stream sequence:

  1. First message: notifications/subscriptions/acknowledged whose notifications is the subset the server will honor; unsupported types are omitted (schema $defs.SubscriptionsAcknowledgedNotificationParams). snodo keeps only true flags whose capability is advertised, and resourceSubscriptions only when resources.subscribe is true (snodo:lib/snodo/subscription.ex:360-395, shaping v26:648-657):

    {"jsonrpc": "2.0", "method": "notifications/subscriptions/acknowledged",
     "params": {"notifications": {"toolsListChanged": true},
                "_meta": {"io.modelcontextprotocol/subscriptionId": 9}}}
    
  2. Then events, each with params._meta["io.modelcontextprotocol/subscriptionId"] equal to the listen request's id (schema $defs.NotificationMetaObject; v26:660-671, 1490-1505): notifications/tools/list_changed, notifications/prompts/list_changed, notifications/resources/list_changed (params only _meta), notifications/resources/updated (params uri required, may be a sub-resource of the subscribed one).

  3. Graceful end (for example server shutdown): the JSON-RPC response to the listen request (schema $defs.SubscriptionsListenResult; _meta with subscriptionId required):

    {"jsonrpc": "2.0", "id": 9, "result": {"resultType": "complete",
      "_meta": {"io.modelcontextprotocol/subscriptionId": 9,
                "io.modelcontextprotocol/serverInfo": {"name": "s", "version": "1"}}}}
    

    An abrupt transport close carries no response (schema description). The client ends a stream by closing the HTTP connection, or with notifications/cancelled on stdio (snodo:lib/snodo/client/http.ex:49-55, snodo:guides/protocol-compliance.md:107-113).

HTTP: the listen response is always SSE (v26:708-720 stream_mode: :sse); snodo sends : keepalive\r\n\r\n comments every 15 s by default (streamable_http/server.ex:104, 1083). snodo bounds open streams (default 256, 503 above) (snodo:guides/transports.md:154-156).

Runner checks (server-stateless): the ack is the first frame; every notification frame on the stream has the subscription id; a stream filtered to promptsListChanged receives no tools/list_changed after test_trigger_tool_change; with tools.listChanged/prompts.listChanged advertised, calling test_trigger_tool_change/test_trigger_prompt_change on a separate request produces the matching notification within 1.5 s (WARNING only, SHOULD).

12. Error codes

CodeNameHTTP (snodo, 2026-07-28)dataSource
-32700Parse error400nonesnodo:lib/snodo/error.ex:25, streamable_http.ex:38
-32600Invalid Request400noneerror.ex:30
-32601Method not found404noneerror.ex:38, streamable_http.ex:40, runner server-stateless
-32602Invalid params400optional, e.g. {"uri": ...}, {"missing": [...]}error.ex:49
-32603Internal error500none (cause is never sent)error.ex:59, streamable_http.ex:42
-32020HeaderMismatch400snodo sends {"header": h, "body": b} for the version mismatch, nothing for other header failuresschema $defs.HeaderMismatchError, v26:1052-1059, streamable_http.ex:557-559
-32021MissingRequiredClientCapability400{"requiredCapabilities": ClientCapabilities} (required)schema $defs.MissingRequiredClientCapabilityError, mrtr.ex:142-148
-32022UnsupportedProtocolVersion400{"requested": string, "supported": [string]} (both required)schema $defs.UnsupportedProtocolVersionError, v26:1066-1075
-32602 + data.uriresource not found (SEP-2164)400{"uri": requested}runner sep-2164-resource-not-found, router.ex:553
-32000snodo client-side transport errorn/avariessnodo:lib/snodo/client.ex:590-596

Other codes in -32000 to -32099 are left to applications (error.ex:62-71). The schema says the three MCP-specific errors MUST use 400 on HTTP. Any other code maps to HTTP 200 in snodo (streamable_http.ex:591-595). The runner requires errors to echo the request id (sep-2575-http-server-error-jsonrpc-id).

Fixed messages snodo uses: Protocol header does not match request metadata (-32020 version), Missing or repeated required header: <name>, <name> does not match the request body, <name> is malformed (-32020 other headers), Missing required client capability (-32021), Unsupported protocol version (-32022). The runner checks codes and data, not messages.

13. Streamable HTTP for 2026-07-28

13.1 Endpoint and methods

  • One endpoint, POST only. snodo serves /mcp; other paths get 404 (streamable_http/server.ex:100, 981-982).
  • GET and DELETE get 405 with allow: POST and a -32600 body Streamable HTTP accepts POST only (streamable_http.ex:171-175, 609-621; snodo:guides/transports.md:105). There is no standalone GET event stream in this revision.
  • No session ids. The 2026-07-28 policy declares allow_session_id?: false (v26:694) but snodo does not enforce it; a stray Mcp-Session-Id is ignored. Nothing in the runner's 2026-07-28 server scenarios sends one.

13.2 Admission order (snodo, streamable_http.ex:51-110)

  1. HTTP method POST, else 405.
  2. Origin (section 13.7), else 403.
  3. Host if configured, else 403.
  4. Content-Type exactly application/json (parameters allowed), else 415 -32600 Expected application/json (264-272).
  5. Accept must list both application/json and text/event-stream, else 406 -32600 (274-283).
  6. Body JSON (a leading UTF-8 BOM is skipped), else 400 -32700 with id: null (285-293).
  7. A response object gets 202 with no body (297-299).
  8. Envelope decode errors: 400 with the readable id.
  9. Version header checks (section 13.4).
  10. Required and mirrored headers (13.5, 13.6): 400 -32020.
  11. Execute. Notifications: 202 empty body (131-132). Results and errors: JSON, status from the table in section 12, or SSE (section 8.3).

The runner always sends Content-Type: application/json and Accept: application/json, text/event-stream (runner yt). server-sse-multiple-streams sends Accept: text/event-stream, application/json.

13.3 Response format

  • application/json with one JSON-RPC response for ordinary results.
  • text/event-stream when progress or log notifications are emitted for the request (section 8.3) and always for subscriptions/listen.
  • Either is accepted by the runner; it parses SSE data: lines when the content type is text/event-stream (runner wt, Ct).

13.4 MCP-Protocol-Version

  • Required on every POST (v26:693, required_headers v26:695, 713, 727). Missing: 400 -32020 (snodo Missing or repeated required header: mcp-protocol-version; runner mock It: Missing MCP-Protocol-Version header).
  • Must equal _meta["io.modelcontextprotocol/protocolVersion"], else 400 -32020 with data: {"header": ..., "body": ...} (streamable_http.ex:331-357; schema RequestMetaObject says "MUST return a 400 Bad Request").
  • Header equal to body but not enabled: 400 -32022 with requested and supported (streamable_http.ex:337-341, 571-581).
  • Runner server-stateless probes: header and body both v999.0.0 expects -32022, 400, data.requested == "v999.0.0", data.supported a non-empty subset of discover's supportedVersions; header 2026-07-28 with body v999.0.0 expects 400 and -32020.
  • When the body has no version (missing _meta), snodo skips the mirror comparison and the dialect reports -32602 (streamable_http.ex:486-488).

13.5 Mcp-Method and Mcp-Name (SEP-2243)

  • Mcp-Method is required on every POST and must equal the body method exactly (case-sensitive value) (v26:695-699, 727-731).
  • Mcp-Name is required for tools/call and prompts/get (equals params.name) and resources/read (equals params.uri) (v26:689-706, 748-749). Its value may be base64-wrapped (13.6 rules).
  • Header names are case-insensitive; values are trimmed of surrounding whitespace before comparison; a repeated header is an error (streamable_http.ex:637-655, 456-461).
  • Failures: 400 -32020.
  • Runner client helper sends MCP-Protocol-Version, Mcp-Method, and Mcp-Name for tools/call, prompts/get, resources/read (and tasks/get|update|cancel with taskId) (runner vt, yt).
  • The pending, unscored server scenario http-header-validation checks: mismatched Mcp-Method, missing Mcp-Method, mismatched Mcp-Name, missing Mcp-Name, and Mcp-Method: TOOLS/LIST are each rejected with 400 and -32020; <name> with spaces and a lowercase mcp-method header name are accepted. It uses tools[0] from tools/list and calls it with arguments: {}, so the alphabetically first tool must accept empty arguments (snodo names it a_header_probe, fixture:fixture.ex:17-24).

13.6 Mcp-Param-* headers (x-mcp-header)

  • A tool input property {"type": "string", "x-mcp-header": "Region"} means the client sends Mcp-Param-Region: <value> on tools/call, and the server checks it against params.arguments.region (snodo:lib/snodo/transport/param_headers.ex:4-12).
  • Valid annotation: non-empty RFC 9110 token (^[!#$%&'*+\-.^_|~0-9A-Za-z]+$), propertytypeexactlystring,integer, orboolean, unique ignoring case, reached from the root only throughproperties(param_headers.ex:9-16,116-161). The runner's client scenariohttp-invalid-tool-headersalso rejectsnumber,object,array,null` types, empty names, duplicates (same or different case), space, colon, non-ASCII, and control characters in names.
  • Value encoding: strings as is, booleans true/false, integers decimal (param_headers.ex:57-62). A value that is not plain printable ASCII, or that has leading or trailing whitespace, is sent as =?base64?<base64 of UTF-8>?= (snodo:lib/snodo/client/http.ex:382-388, 569-574; runner client helper Ns/Ps). Internal spaces stay plain.
  • A null or absent argument sends no header (runner http-custom-headers, check sep-2243-client-omit-null).
  • Server side (snodo): header present in body but missing from headers is 400 -32020; a base64 wrapper that does not decode to valid UTF-8 is malformed (400 -32020); a plain value must be visible ASCII, space, or tab; integers compare numerically (streamable_http.ex:524-555, param_headers.ex:64-77). Exercised by the pending, unscored http-custom-header-server-validation scenario.

13.7 DNS rebinding protection

Runner dns-rebinding-protection (requires a localhost URL; the server URL hostname must be localhost, 127.0.0.1, or [::1]):

  • POSTs server/discover with Host: evil.example.com and Origin: http://evil.example.com; expects any 4xx.
  • POSTs the same with Host and Origin set to the server's real host and port; expects 2xx.

snodo checks Origin by default against loopback hosts (127.0.0.1, localhost, ::1); a non-allowed, duplicate, non-http(s), or userinfo-bearing Origin is 403 -32600 Origin is not allowed. A request without Origin passes. Host checking is opt-in (allowed_hosts), 403 Host is not allowed (streamable_http.ex:36, 177-262). Either check alone satisfies the scenario because the runner sends both headers.

13.8 server-sse-multiple-streams

For 2026-07-28 the runner sends three concurrent POSTs of tools/list (ids 1000 to 1002, clientCapabilities: {}, Accept: text/event-stream, application/json) and requires all three to return HTTP 2xx (FAILURE otherwise). If any response is SSE, it reads one event per stream within 2 s (SUCCESS if all work, WARNING if some). All-JSON responses give an INFO check, which is what snodo produces (snodo baseline server-sse-streams-functional: INFO).

14. stdio

  • Newline-delimited JSON, one message per line; stdout carries only protocol messages (snodo:guides/transports.md:38-79).
  • Requests may run concurrently; responses are written whole.
  • No headers, so no -32020 checks; version comes from _meta only.
  • Oversize line: -32600 with id: null; unreadable input: -32700 with id: null (snodo:lib/snodo/transport/stdio.ex:17-21, 310).
  • Progress and log notifications are lines written before the response.
  • notifications/cancelled stops a request with no late response.
  • The official conformance server leg drives HTTP only (--url); stdio is not scored by the runner.

15. Initialize-era differences (2025-11-25, 2025-06-18)

snodo can enable these dialects next to 2026-07-28 (snodo:guides/initialize-era-clients.md, snodo:lib/snodo/protocol/legacy.ex):

  • Handshake: initialize with params.protocolVersion, params.capabilities, params.clientInfo (name, version required) (legacy.ex:338-354). Result: {"protocolVersion", "capabilities", "serverInfo", "instructions"?} with no resultType (snodo:lib/snodo/server.ex:651-664). Then notifications/initialized, which over HTTP gets an empty 202.
  • Capabilities are negotiated once, not per request; there is no request _meta requirement. logging/setLevel and ping exist and return {} (server.ex:666-668).
  • Results omit resultType, ttlMs, cacheScope, and _meta.io.modelcontextprotocol/serverInfo (legacy.ex:448-560). 2025-06-18 also drops icons and websiteUrl (legacy.ex:568).
  • structuredContent and outputSchema must be objects; tools whose schemas are not objects are hidden from tools/list and refused with -32602 (snodo:guides/initialize-era-clients.md, "Shared behavior").
  • inputResponses, requestState, task params are refused: -32602 Tasks and multi-round-trip inputs are not supported (legacy.ex:298-302). Those revisions instead send elicitation/create, sampling/createMessage, roots/list as server-to-client requests, which snodo's server side does not implement.
  • HTTP: MCP-Protocol-Version required after initialize (legacy.ex:580-583, 258-280). Execution errors are sent with HTTP 200, because those clients treat non-2xx as transport failure and 404 as session expiry; admission failures keep 4xx (streamable_http.ex:583-595). The official spec for those revisions has Mcp-Session-Id issued on the initialize response and echoed by the client, plus GET streams and DELETE to end a session; snodo's server issues no session id (GET/DELETE 405), while snodo's client reads and sends Mcp-Session-Id and sends DELETE on close (snodo:lib/snodo/client/http.ex:42-48). The runner's dns-rebinding-protection for those versions also posts notifications/initialized with the session id and expects 2xx.
  • stdio: version negotiated once per connection by initialize.

16. Conformance fixture: what the server scenarios call

The runner's server scenarios call these names. Arguments shown are what the runner sends; "returns" is what passes. snodo sources in fixture:fixture.ex, fixture:mrtr.ex, fixture:stateless.ex, fixture:progress.ex.

16.1 Tools

Every tool needs a description (runner tools-list). Use names matching ^[A-Za-z0-9_.-]{1,128}$.

ToolRunner callMust return
test_simple_text{"name": "test_simple_text"} (no arguments key)content with a text block; snodo text This is a simple text response for testing.
test_image_contentarguments: {}an image block with data and mimeType; snodo uses the 1x1 PNG iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Z4p8AAAAASUVORK5CYII=, image/png
test_audio_contentarguments: {}an audio block, mimeType exactly audio/wav; snodo data UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=
test_embedded_resourcearguments: {}a resource block whose resource has uri, mimeType, and text or blob; snodo: test://embedded-resource, text/plain, This is an embedded resource content.
test_multiple_content_typesarguments: {}at least 2 blocks including text, image, resource; snodo: text Multiple content types test:, the PNG, resource test://mixed-content-resource application/json text {"test":"data","value":123}
test_error_handlingarguments: {}isError: true and content[0].text non-empty; snodo This tool intentionally returns an error for testing
test_tool_with_progressarguments: {}, _meta.progressToken: "progress-test-1"at least 3 non-decreasing notifications/progress for that token before the result; snodo sends 0, 50, 100 of total 100 with messages Starting computation, First half complete, Computation complete, then text Progress computation completed: 5050
test_missing_capabilityarguments: {}, clientCapabilities: {}-32021, HTTP 400, data.requiredCapabilities.sampling an object; snodo always asks for sampling and lets the dialect refuse
test_logging_toolarguments: {}, no logLevela result with no notifications/message frames; snodo logs at every level with logger conformance and returns Logged at every level
test_streaming_elicitationarguments: {}, clientCapabilities: {"elicitation": {}}anything that is not an independent JSON-RPC request on the stream; snodo returns the user_name elicitation as input_required
test_trigger_tool_changearguments: {}a result, and a notifications/tools/list_changed on open listen streams that asked for toolsListChanged
test_trigger_prompt_changearguments: {}same for notifications/prompts/list_changed
test_input_required_result_elicitationarguments: {}round 1: input_required with inputRequests.user_name of method elicitation/create (form with name string); round 2 with inputResponses.user_name = {"action":"accept","content":{"name":"Alice"}}: complete with non-empty content (snodo Hello, Alice!). Also used by missing-input-response, result-type, ignore-extra-params, validate-input
test_input_required_result_samplingarguments: {}round 1: first input request has method sampling/createMessage (snodo id capital_question, text What is the capital of France?, maxTokens: 100); round 2 with a CreateMessageResult: complete
test_input_required_result_list_rootsarguments: {}round 1: first input request is roots/list (snodo id client_roots, params: {}); round 2 with {"roots":[{"uri":"file:///test/root","name":"Test Root"}]}: complete
test_input_required_result_request_statearguments: {}round 1: input_required with inputRequests (snodo confirm, boolean ok) and a string requestState; round 2 with {"action":"accept","content":{"ok":true}} and the echoed state: complete, text should contain state-ok (description says MUST; code checks only completeness)
test_input_required_result_multiple_inputsarguments: {}round 1: at least 3 input requests with all three methods and a requestState; round 2 with all answers: complete
test_input_required_result_multi_roundarguments: {}round 1: input_required with requests and requestState; round 2 (answer {"name":"Alice"}): another input_required with a different requestState; round 3 (answer {"color":"blue"}): complete. snodo ids step1, step2
test_input_required_result_tampered_statearguments: {}round 1: input_required with a requestState; round 2 with requestState + "-TAMPERED": a JSON-RPC error (snodo -32602 Invalid or expired request state)
test_input_required_result_capabilitiesarguments: {}, clientCapabilities: {"sampling": {}}input_required whose requests include no elicitation/create and are not empty (snodo sends only greeting sampling)
a_header_probe, custom_header_region, json_schema_2020_12_toolpending scenarios onlysee 13.5, 13.6; json_schema_2020_12_tool must list its inputSchema with $schema, $defs (with $anchor), $ref, allOf/anyOf, if/then/else, additionalProperties: false unchanged (fixture:fixture.ex:143-180)

input-required-result-validate-input sends inputResponses: {"user_name": 12345} and then inputResponses: null to the elicitation tool; a complete result for either is a WARNING. snodo rejects both at admission with -32602.

16.2 Resources

URIRunner useMust return
test://static-textresources-read-textcontents[0] with uri, mimeType, non-empty text (snodo text/plain, This is a static text resource for testing.)
test://static-binaryresources-read-binarycontents[0] with uri, mimeType, non-empty blob (snodo application/octet-stream, base64 of bytes 00 01 02 03 7F 80 FE FF = AAECA3+A/v8=)
template test://template/{id}/dataresources-templates-read reads test://template/123/datatext containing 123 (snodo Template resource data for id 123)
any unknown URIsep-2164-resource-not-found reads test://nonexistent-resource-for-conformance-testing-32602 with data.uri
the first URI in resources/listcachinga read result with ttlMs, cacheScope; must not need input

snodo adds z-mrtr://input-required-preview, which elicits user_context first; it is not required by any scored scenario (fixture:mrtr.ex:490-511).

16.3 Prompts

PromptRunner callMust return
test_simple_promptno argumentsnon-empty messages, each with role and content
test_prompt_with_argumentsarguments: {"arg1": "testValue1", "arg2": "testValue2"}messages whose JSON contains both values; declares arg1, arg2 required; also the completion target (returns [value], total: 1, hasMore: false in snodo)
test_prompt_with_embedded_resourcearguments: {"resourceUri": "test://example-resource"}a message whose content.type is resource
test_prompt_with_imageno argumentsa message with content.type: "image", data, mimeType
test_input_required_result_promptprompts/get, no argumentsround 1: input_required with inputRequests (snodo user_context, string context); round 2 with {"context": "test context"}: complete with messages

16.4 Capabilities and server identity

Advertise at least tools, resources, prompts, completions, logging; tools.listChanged and prompts.listChanged (with working triggers) make the subscription checks succeed instead of skipping. Return _meta.serverInfo on every result. snodo's fixture identifies as {"name": "snodo-conformance", "version": "0.1.0"} and uses ttlMs: 0, cacheScope: "private" everywhere (fixture:fixture.ex:396-419).

17. Scored server scenarios at a glance

37 scenarios (snodo:conformance/requirements/2026-07-28.yaml:38-75). Each also gets a wire-schema-valid check except dns-rebinding-protection and server-sse-multiple-streams (snodo:conformance/expected-failures.json inventory).

ScenarioCore assertion
server-statelesssections 2, 4, 5.7, 8.1, 10.2, 11, 13.4 (28 checks)
completion-complete5.6
tools-list5.1
tools-call-simple-text, -image, -audio, -embedded-resource, -mixed-content, -error, -with-progress16.1
server-sse-multiple-streams13.8
resources-list, resources-read-text, resources-read-binary, resources-templates-read5.3, 5.4, 16.2
sep-2164-resource-not-found5.4
prompts-list, prompts-get-simple, -with-args, -embedded-resource, -with-image5.5, 16.3
dns-rebinding-protection13.7
caching7
input-required-result-* (14)10, 16.1

Unscored but run: json-schema-2020-12, http-header-validation, http-custom-header-server-validation (pending), and ten tasks-* extension scenarios (requirements/2026-07-28.yaml:111-197).

The official runner's exit code reflects its own pass/fail. snodo's report.mjs additionally treats a whole scenario as passed only with no FAILURE, WARNING, or SKIPPED checks (snodo:conformance/results/*-summary.json score.basis).

18. Client-leg scenarios (2026-07-28 required set, non-auth)

The runner starts one scenario server per scenario and runs the client command against it (section 19.2). Every message the client sends is schema validated (wire-schema-valid).

ScenarioServer it runsWhat the client must do
tools_callstateless mock (It): validates headers and _meta as in section 2, serves server/discover, tools/list with add_numbers (a, b numbers required)call add_numbers with numeric a and b. Check tool-add-numbers (runner tools_call)
request-metadataraw server at http://localhost:<port> (no /mcp path); POST only, 405 otherwisefirst request is answered 400 -32022 {"supported": ["2026-07-28"], "requested": ...}; every request must send MCP-Protocol-Version, _meta with protocolVersion and clientCapabilities, SHOULD send clientInfo (WARNING), header equal to _meta version; roots, sampling, elicitation capabilities each SKIPPED if absent and FAILURE if not an object; sep-2575-client-retry-supported-version becomes SUCCESS when a later request carries 2026-07-28 in both header and _meta. All eight declared checks must be observed. snodo passes whole only because it declares all three capabilities (snodo:conformance/README.md:258-263)
sep-2322-client-request-statemock at /mcp with tools test_mrtr_echo_state, test_mrtr_no_state, test_mrtr_unrelated, test_mrtr_no_result_typeecho requestState byte for byte; use a different JSON-RPC id on retry; omit requestState when the server sent none; never carry inputResponses/requestState onto another tool's call; treat a result without resultType as complete and not retry. The input_required results ask confirm via form {"confirmed": boolean} (no required list). All five checks must be emitted, so the client must call all four tools
http-standard-headersmock (Bt subclass) with tools test_headers, my-hyphenated-tool; resources file:///path/to/file%20name.txt, https://example.com/resource?id=123 (read returns contents: []); prompt test_prompt (get returns messages: [])send correct Mcp-Method on every method and Mcp-Name on tools/call, resources/read, prompts/get. Checks for initialize and notifications/initialized are SKIPPED for a 2026-07-28 client, so the scenario cannot be a clean pass (snodo README:283-286)
http-custom-headersmock with test_custom_headers (annotated params of each type, unannotated float_val number and query) and test_custom_headers_null; arguments arrive in MCP_CONFORMANCE_CONTEXT.toolCallscall both tools with exactly the context arguments; send Mcp-Param-<Name> per section 13.6, never for unannotated params, never for null values. Notable: x-mcp-header: "Method" produces Mcp-Param-Method, not Mcp-Method
http-invalid-tool-headersmock listing valid_tool plus eleven tools with invalid x-mcp-header annotationsrequest tools/list, call valid_tool, and never call any invalid tool (section 13.6 rules)
json-schema-ref-no-derefserver at /mcp whose tool inputSchema has a $ref to http://localhost:<port>/canary/profile-schema.json (backed internally by the TypeScript SDK)call tools/list and never fetch the canary URL; same-document #/$defs/... refs are fine

Client behavior that serves all of these (snodo's harness, snodo:conformance/client.exs:12-33): server/discover, tools/list, call the tools named in MCP_CONFORMANCE_CONTEXT.toolCalls or else every listed tool with arguments sampled from its schema, answer input_required with handlers for form, URL, sampling, and roots, retry with the returned requestState, and list/read resources and list/get prompts when advertised.

19. How run.mjs launches things

19.1 Server leg

From snodo:conformance/run.mjs:

  1. Verify the vendored requirements file hash and that it matches the runner's copy; check the runner version is the pinned 0.2.0-alpha.12 (run.mjs:16-23, 58-82).
  2. Spawn the fixture: mix run --no-compile --no-deps-check ../fixture_server.exs in conformance/fixture, env MCP_PORT=0 (OS-assigned port), MCP_CONFORMANCE_MANAGED=1, MCP_FIXTURE_PROFILE, MCP_FIXTURE_TRANSPORT, stdio piped (run.mjs:91-97).
  3. Readiness: read fixture stdout lines until one parses as JSON with "conformanceReady": true; take its url; assert the hostname is 127.0.0.1; time out after 30 s; fail if the fixture exits first (run.mjs:109-126). The fixture prints {"conformanceReady":true,"url":"http://127.0.0.1:<port>/mcp"} and then blocks reading stdin until EOF (fixture_server.exs:107-110).
  4. Run the runner: node <pkg>/dist/index.js server --url <url> --requirements 2026-07-28 --output-dir <runDir> (run.mjs:127, 131-137). Without a frozen set it uses --spec-version <v> --suite active. Kill after 240 s; accept exit codes 0 and 1 (run.mjs:143-147).
  5. Collect checks.json from each server-<scenario>-<ISO timestamp>/ directory under the output dir (run.mjs:57, 149-157).
  6. Close the fixture's stdin and require exit code 0 within 5 s (run.mjs:186-198).

Other runner server options: --scenario <name>, --suite active|all|draft|pending, --spec-version, --expected-failures <yaml>, --timeout <ms> (default 30000 per scenario), --force, --verbose (runner CLI definition Nu.command("server")).

Manual equivalent (snodo:guides/protocol-compliance.md:313-323):

npx -y @modelcontextprotocol/conformance@0.2.0-alpha.12 server \
  --url http://127.0.0.1:3001/mcp \
  --requirements 2026-07-28 \
  --output-dir conformance-results

19.2 Client leg

  • run.mjs runs node <pkg>/dist/index.js client --command "mix run --no-compile --no-deps-check ../client.exs" --requirements 2026-07-28 --output-dir <runDir> with env SNODO_CLIENT_PROTOCOL=2026-07-28 (run.mjs:128-137). Artifacts are <scenario>-<timestamp>/checks.json; auth/... scenarios nest a directory (run.mjs:148-157).
  • The runner splits the command string on spaces, appends the scenario server URL as the last argument, and spawns it with shell: true. It sets MCP_CONFORMANCE_SCENARIO=<scenario>, MCP_CONFORMANCE_PROTOCOL_VERSION (the run's spec version; 2025-11-25 if none applies), and, when the scenario supplies one, MCP_CONFORMANCE_CONTEXT as JSON {"name": <scenario>, ...context}. The client is killed after the timeout (default 30000 ms) (runner Ac, Nc). The client's exit code is reported but scoring comes from the traffic the scenario server recorded.
  • Other runner client options: --scenario, --suite, --timeout, --expected-failures (runner Nu.command("client")).

20. Gaps and disagreements

  • HeaderMismatchError in the schema does not require data; snodo adds {"header", "body"} only for the version mismatch. Either is valid.
  • ping: absent from the 2026-07-28 schema and required to be -32601/404 by the runner. snodo's initialize-era dialects still answer it.
  • notifications/message is marked deprecated in snodo's profile; neither the schema nor the runner says so. Treat the deprecation as unverified.
  • HTTP cancellation semantics (connection close cancels; posted notifications/cancelled scoping; 204 for a cancelled request) come only from snodo design notes, not from the schema or a scored scenario.
  • resultType values other than complete and input_required (for example task) belong to extensions; the core schema types resultType as a plain string.
  • Progress: snodo requires strictly increasing values; the runner accepts non-decreasing. Strictly increasing satisfies both.
  • server/discover ttlMs/cacheScope are schema-required but not asserted by the caching scenario; they are still checked by wire-schema-valid.
  • The request-state scenario description says the round-2 text MUST contain state-ok; its code checks only that the result is complete.
  • allow_session_id?: false is declared in snodo's 2026-07-28 policy but not enforced; I found no rule saying a server must reject a stray Mcp-Session-Id.
  • json-schema-ref-no-deref's server rewrites a 2026-07-28 header to a bundled constant before handing the request to the TypeScript SDK; I did not resolve which version that constant is, so the exact response shape the client sees there is unverified.
  • Specification prose was not available locally; MUST/SHOULD wording above is quoted from schema descriptions and runner check descriptions only.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close