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:
| Tag | Source |
|---|---|
v26 | snodo lib/snodo/protocol/v2026_07_28.ex |
snodo:<path> | another file under the snodo checkout (~/Code/github.com/joshrotenberg/snodo/) |
schema | snodo:interop/schema_validation/schema.json, the official 2026-07-28 JSON schema pinned at modelcontextprotocol commit 5f5440bb (provenance.json). Cited as schema $defs.Name. |
runner | the 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. |
fixture | snodo: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).
"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).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).v26:88 batching: :forbidden). A
JSON array is not a map, so it is rejected as -32600 Invalid Request
(envelope.ex:43).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).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)._metaEvery request's params carries _meta (schema $defs.RequestParams
requires _meta; $defs.RequestMetaObject):
| Key | Required | Value |
|---|---|---|
io.modelcontextprotocol/protocolVersion | yes | string, e.g. "2026-07-28" |
io.modelcontextprotocol/clientCapabilities | yes | ClientCapabilities object; {} means none |
io.modelcontextprotocol/clientInfo | no (SHOULD) | Implementation: name, version required; title, description, icons, websiteUrl optional |
io.modelcontextprotocol/logLevel | no | one of debug info notice warning error critical alert emergency |
progressToken | no | string 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):
_meta absent is treated as {}; _meta not an object is -32602 _meta must be an object (v26:1024-1030)._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).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).logLevel, if present, must be a known level, else -32602 Invalid MCP request log level (v26:1208-1214).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).MCP-Protocol-Version header that differs from the body value is -32020 (section 13.4) (v26:1044-1061).2026-07-28 is -32022 with data.requested and data.supported (v26:1063-1076).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).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).
From schema $defs.MetaObject and the snodo regex at v26:42:
/. Each label starts with a
letter, ends with a letter or digit, and has letters, digits, or - inside.[A-Za-z0-9] with [A-Za-z0-9._-] inside.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").
resultType and result _metaresultType (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.
server/discoverRequest: 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):
| Key | Shape |
|---|---|
tools | {"listChanged"?: bool} |
resources | {"listChanged"?: bool, "subscribe"?: bool} |
prompts | {"listChanged"?: bool} |
completions | JSON object ({}) |
logging | JSON object ({}) |
experimental | map of name to object |
extensions | map 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).
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).
tools/listParams: 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).
tools/callParams (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: <name> (snodo:lib/snodo/router.ex:549).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):
type | Required fields | Optional |
|---|---|---|
text | text | annotations, _meta |
image | data (base64), mimeType | annotations, _meta |
audio | data (base64), mimeType | annotations, _meta |
resource_link | uri, name | title, description, mimeType, size, icons, annotations, _meta |
resource | resource: a TextResourceContents or BlobResourceContents | annotations, _meta |
Annotations: audience (array of "user"/"assistant"), priority
(0 to 1), lastModified string (schema $defs.Annotations).
resources/list and resources/templates/listParams: 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.
resources/readParams (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.
prompts/list and prompts/getprompts/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"}}
]}
completion/completeParams (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).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.
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).
tools/list, prompts/list, resources/list,
resources/templates/list (snodo:lib/snodo/pagination.ex:20-21).cursor is an opaque string; result nextCursor is present only
when more items may follow (schema $defs.PaginatedResult). Absent
nextCursor means last page."mcp1." + base64url(JSON [1, protocolVersion, method, offset, catalogHash]),
max 2048 bytes, default page size 100 (pagination.ex:15-18, 135-147).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.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).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.
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).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"}}
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._meta.progressToken (string or integer). The server
is not obliged to send progress (schema $defs.RequestMetaObject).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"}}
progress values and caps updates at
1000 per request and messages at 4 KiB (snodo:lib/snodo/progress.ex:1-18).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.snodo:lib/snodo/logging.ex:28-30).subscriptions/listen gets no progress binding (v26:329-331).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).snodo:guides/transports.md:44-48).Notification (schema $defs.CancelledNotificationParams: required
requestId; optional reason string, _meta):
{"jsonrpc": "2.0", "method": "notifications/cancelled",
"params": {"requestId": 7, "reason": "User cancelled"}}
v26:44-46, 181-188).requestId must be a bounded id, reason a string
(v26:965-983). Invalid ones are ignored, as notifications are never answered.snodo:guides/transports.md:44-48). A subscriptions/listen on stdio is
closed the same way.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.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).
input_required resultschema $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).Before sending an input_required result, the server checks each input
request against this request's clientCapabilities (mrtr.ex:126-150):
| Request | Needs |
|---|---|
elicitation/create form | elicitation present; {} counts as form support, otherwise elicitation.form (elicitation.ex:280-281) |
elicitation/create url | elicitation.url |
sampling/createMessage | sampling; plus sampling.tools if tools or toolChoice is set; plus sampling.context if includeContext is not "none" (sampling.ex:120-132) |
roots/list | roots (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.
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:
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).input-required-result-ignore-extra-params).input_required, not an error
(runner input-required-result-missing-input-response, WARNING otherwise).mrtr.ex:5-9).requestState integrityThe 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):
mrtr1.<b64url(json)>.<b64url(hmac)> (single key) or
mrtr2.<keyId>.<b64url(json)>.<b64url(hmac)> (keyring) (state.ex:66-110).[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).HMAC-SHA256(secret, "snodo-mrtr-token-v1:" + encoded) (v2 adds
the key id) (state.ex:274-282).state.ex:70-75).Invalid or expired request state with no data
(state.ex:121-134).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).
subscriptions/listenRequest (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).schema $defs.SubscriptionFilter).Stream sequence:
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}}}
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).
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).
| Code | Name | HTTP (snodo, 2026-07-28) | data | Source |
|---|---|---|---|---|
| -32700 | Parse error | 400 | none | snodo:lib/snodo/error.ex:25, streamable_http.ex:38 |
| -32600 | Invalid Request | 400 | none | error.ex:30 |
| -32601 | Method not found | 404 | none | error.ex:38, streamable_http.ex:40, runner server-stateless |
| -32602 | Invalid params | 400 | optional, e.g. {"uri": ...}, {"missing": [...]} | error.ex:49 |
| -32603 | Internal error | 500 | none (cause is never sent) | error.ex:59, streamable_http.ex:42 |
| -32020 | HeaderMismatch | 400 | snodo sends {"header": h, "body": b} for the version mismatch, nothing for other header failures | schema $defs.HeaderMismatchError, v26:1052-1059, streamable_http.ex:557-559 |
| -32021 | MissingRequiredClientCapability | 400 | {"requiredCapabilities": ClientCapabilities} (required) | schema $defs.MissingRequiredClientCapabilityError, mrtr.ex:142-148 |
| -32022 | UnsupportedProtocolVersion | 400 | {"requested": string, "supported": [string]} (both required) | schema $defs.UnsupportedProtocolVersionError, v26:1066-1075 |
-32602 + data.uri | resource not found (SEP-2164) | 400 | {"uri": requested} | runner sep-2164-resource-not-found, router.ex:553 |
| -32000 | snodo client-side transport error | n/a | varies | snodo: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.
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.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.streamable_http.ex:51-110)Origin (section 13.7), else 403.Host if configured, else 403.Content-Type exactly application/json (parameters allowed), else 415 -32600 Expected application/json (264-272).Accept must list both application/json and text/event-stream, else 406 -32600 (274-283).id: null (285-293).297-299).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.
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.data: lines when the
content type is text/event-stream (runner wt, Ct).MCP-Protocol-Versionv26: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)._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").requested and
supported (streamable_http.ex:337-341, 571-581).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._meta), snodo skips the mirror
comparison and the dialect reports -32602 (streamable_http.ex:486-488).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).streamable_http.ex:637-655, 456-461).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).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).Mcp-Param-* headers (x-mcp-header){"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).^[!#$%&'*+\-.^_|~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.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.http-custom-headers,
check sep-2243-client-omit-null).streamable_http.ex:524-555,
param_headers.ex:64-77). Exercised by the pending, unscored
http-custom-header-server-validation scenario.Runner dns-rebinding-protection (requires a localhost URL; the server URL
hostname must be localhost, 127.0.0.1, or [::1]):
server/discover with Host: evil.example.com and
Origin: http://evil.example.com; expects any 4xx.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.
server-sse-multiple-streamsFor 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).
snodo:guides/transports.md:38-79)._meta only.id: null; unreadable input: -32700 with
id: null (snodo:lib/snodo/transport/stdio.ex:17-21, 310).notifications/cancelled stops a request with no late response.--url); stdio is not
scored by the runner.snodo can enable these dialects next to 2026-07-28
(snodo:guides/initialize-era-clients.md, snodo:lib/snodo/protocol/legacy.ex):
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._meta requirement. logging/setLevel and ping exist and return {}
(server.ex:666-668).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.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.initialize.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.
Every tool needs a description (runner tools-list). Use names matching
^[A-Za-z0-9_.-]{1,128}$.
| Tool | Runner call | Must 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_content | arguments: {} | an image block with data and mimeType; snodo uses the 1x1 PNG iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Z4p8AAAAASUVORK5CYII=, image/png |
test_audio_content | arguments: {} | an audio block, mimeType exactly audio/wav; snodo data UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA= |
test_embedded_resource | arguments: {} | 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_types | arguments: {} | 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_handling | arguments: {} | isError: true and content[0].text non-empty; snodo This tool intentionally returns an error for testing |
test_tool_with_progress | arguments: {}, _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_capability | arguments: {}, clientCapabilities: {} | -32021, HTTP 400, data.requiredCapabilities.sampling an object; snodo always asks for sampling and lets the dialect refuse |
test_logging_tool | arguments: {}, no logLevel | a result with no notifications/message frames; snodo logs at every level with logger conformance and returns Logged at every level |
test_streaming_elicitation | arguments: {}, 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_change | arguments: {} | a result, and a notifications/tools/list_changed on open listen streams that asked for toolsListChanged |
test_trigger_prompt_change | arguments: {} | same for notifications/prompts/list_changed |
test_input_required_result_elicitation | arguments: {} | 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_sampling | arguments: {} | 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_roots | arguments: {} | 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_state | arguments: {} | 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_inputs | arguments: {} | 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_round | arguments: {} | 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_state | arguments: {} | 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_capabilities | arguments: {}, 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_tool | pending scenarios only | see 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.
| URI | Runner use | Must return |
|---|---|---|
test://static-text | resources-read-text | contents[0] with uri, mimeType, non-empty text (snodo text/plain, This is a static text resource for testing.) |
test://static-binary | resources-read-binary | contents[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}/data | resources-templates-read reads test://template/123/data | text containing 123 (snodo Template resource data for id 123) |
| any unknown URI | sep-2164-resource-not-found reads test://nonexistent-resource-for-conformance-testing | -32602 with data.uri |
the first URI in resources/list | caching | a 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).
| Prompt | Runner call | Must return |
|---|---|---|
test_simple_prompt | no arguments | non-empty messages, each with role and content |
test_prompt_with_arguments | arguments: {"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_resource | arguments: {"resourceUri": "test://example-resource"} | a message whose content.type is resource |
test_prompt_with_image | no arguments | a message with content.type: "image", data, mimeType |
test_input_required_result_prompt | prompts/get, no arguments | round 1: input_required with inputRequests (snodo user_context, string context); round 2 with {"context": "test context"}: complete with messages |
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).
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).
| Scenario | Core assertion |
|---|---|
server-stateless | sections 2, 4, 5.7, 8.1, 10.2, 11, 13.4 (28 checks) |
completion-complete | 5.6 |
tools-list | 5.1 |
tools-call-simple-text, -image, -audio, -embedded-resource, -mixed-content, -error, -with-progress | 16.1 |
server-sse-multiple-streams | 13.8 |
resources-list, resources-read-text, resources-read-binary, resources-templates-read | 5.3, 5.4, 16.2 |
sep-2164-resource-not-found | 5.4 |
prompts-list, prompts-get-simple, -with-args, -embedded-resource, -with-image | 5.5, 16.3 |
dns-rebinding-protection | 13.7 |
caching | 7 |
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).
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).
| Scenario | Server it runs | What the client must do |
|---|---|---|
tools_call | stateless 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-metadata | raw server at http://localhost:<port> (no /mcp path); POST only, 405 otherwise | first 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-state | mock at /mcp with tools test_mrtr_echo_state, test_mrtr_no_state, test_mrtr_unrelated, test_mrtr_no_result_type | echo 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-headers | mock (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-headers | mock 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.toolCalls | call 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-headers | mock listing valid_tool plus eleven tools with invalid x-mcp-header annotations | request tools/list, call valid_tool, and never call any invalid tool (section 13.6 rules) |
json-schema-ref-no-deref | server 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.
run.mjs launches thingsFrom snodo:conformance/run.mjs:
0.2.0-alpha.12
(run.mjs:16-23, 58-82).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)."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).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).checks.json from each server-<scenario>-<ISO timestamp>/
directory under the output dir (run.mjs:57, 149-157).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
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).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.client options: --scenario, --suite, --timeout,
--expected-failures (runner Nu.command("client")).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.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.server/discover ttlMs/cacheScope are schema-required but not asserted
by the caching scenario; they are still checked by wire-schema-valid.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.Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |