|
| 1 | +"""Requirements manifest for the interaction-model test suite. |
| 2 | +
|
| 3 | +Every user-facing behaviour the SDK must satisfy, keyed by a stable `<area>:<feature>[:<variant>]` |
| 4 | +ID. Each entry owns the tests that exercise it: tests declare `@requirement("<id>")` and |
| 5 | +`test_coverage.py` enforces that every non-deferred requirement is exercised by at least one test. |
| 6 | +
|
| 7 | +Sources: |
| 8 | + spec URL -- externally mandated by the MCP specification (deep link to the section) |
| 9 | + `sdk` -- a behavioural guarantee the SDK chose; not spec-mandated |
| 10 | + `issue:#n` -- regression lock-in for a previously fixed bug |
| 11 | +
|
| 12 | +The `behavior` sentence describes what the suite *asserts* -- which is always the SDK's current |
| 13 | +behaviour. Where that differs from what `source` mandates, the gap is recorded in `divergence` |
| 14 | +and the tests still pin current behaviour: this suite is the parity bar for the receive-path |
| 15 | +rewrite, so a test that fails today proves nothing about equivalence. |
| 16 | +""" |
| 17 | + |
| 18 | +from collections.abc import Callable |
| 19 | +from dataclasses import dataclass |
| 20 | +from typing import TypeVar |
| 21 | + |
| 22 | +import pytest |
| 23 | + |
| 24 | +SPEC_REVISION = "2025-11-25" |
| 25 | +SPEC_BASE_URL = f"https://modelcontextprotocol.io/specification/{SPEC_REVISION}" |
| 26 | + |
| 27 | +_TestFn = TypeVar("_TestFn", bound=Callable[..., object]) |
| 28 | + |
| 29 | + |
| 30 | +@dataclass(frozen=True, kw_only=True) |
| 31 | +class Divergence: |
| 32 | + """A documented gap between the SDK behaviour this suite pins and what `source` mandates.""" |
| 33 | + |
| 34 | + note: str |
| 35 | + issue: str | None = None |
| 36 | + |
| 37 | + |
| 38 | +@dataclass(frozen=True, kw_only=True) |
| 39 | +class Requirement: |
| 40 | + """A single testable behaviour and the provenance of why it must hold.""" |
| 41 | + |
| 42 | + source: str |
| 43 | + behavior: str |
| 44 | + divergence: Divergence | None = None |
| 45 | + deferred: str | None = None |
| 46 | + |
| 47 | + |
| 48 | +REQUIREMENTS: dict[str, Requirement] = { |
| 49 | + # ═══════════════════════════════════════════════════════════════════════════ |
| 50 | + # Protocol primitives |
| 51 | + # ═══════════════════════════════════════════════════════════════════════════ |
| 52 | + "protocol:error:internal-error": Requirement( |
| 53 | + source=f"{SPEC_BASE_URL}/basic#responses", |
| 54 | + behavior="An unhandled exception in a request handler is returned to the caller as a JSON-RPC error.", |
| 55 | + divergence=Divergence( |
| 56 | + note=( |
| 57 | + "The spec reserves -32603 Internal error for this; the low-level Server returns code 0 " |
| 58 | + "(not a defined JSON-RPC code) and leaks str(exc) as the error message." |
| 59 | + ), |
| 60 | + ), |
| 61 | + ), |
| 62 | + # ═══════════════════════════════════════════════════════════════════════════ |
| 63 | + # Ping |
| 64 | + # ═══════════════════════════════════════════════════════════════════════════ |
| 65 | + "ping:client-to-server": Requirement( |
| 66 | + source=f"{SPEC_BASE_URL}/basic/utilities/ping#behavior-requirements", |
| 67 | + behavior="A client-initiated ping receives an empty result from the server.", |
| 68 | + ), |
| 69 | + "ping:server-to-client": Requirement( |
| 70 | + source=f"{SPEC_BASE_URL}/basic/utilities/ping#behavior-requirements", |
| 71 | + behavior="A server-initiated ping receives an empty result from the client.", |
| 72 | + ), |
| 73 | + # ═══════════════════════════════════════════════════════════════════════════ |
| 74 | + # Tools |
| 75 | + # ═══════════════════════════════════════════════════════════════════════════ |
| 76 | + "tools:list:basic": Requirement( |
| 77 | + source=f"{SPEC_BASE_URL}/server/tools#listing-tools", |
| 78 | + behavior="tools/list returns the registered tools with name, description, and inputSchema.", |
| 79 | + ), |
| 80 | + "tools:list:optional-fields": Requirement( |
| 81 | + source=f"{SPEC_BASE_URL}/server/tools#tool", |
| 82 | + behavior=( |
| 83 | + "Optional Tool fields supplied by the server (title, annotations, outputSchema, icons, _meta) " |
| 84 | + "are delivered to the client unchanged." |
| 85 | + ), |
| 86 | + ), |
| 87 | + "tools:call:content:text": Requirement( |
| 88 | + source=f"{SPEC_BASE_URL}/server/tools#text-content", |
| 89 | + behavior="tools/call delivers arguments to the tool handler and returns its text content to the caller.", |
| 90 | + ), |
| 91 | + "tools:call:content:image": Requirement( |
| 92 | + source=f"{SPEC_BASE_URL}/server/tools#image-content", |
| 93 | + behavior="A tool result can carry image content: base64 data with a mimeType.", |
| 94 | + ), |
| 95 | + "tools:call:content:audio": Requirement( |
| 96 | + source=f"{SPEC_BASE_URL}/server/tools#audio-content", |
| 97 | + behavior="A tool result can carry audio content: base64 data with a mimeType.", |
| 98 | + ), |
| 99 | + "tools:call:content:resource-link": Requirement( |
| 100 | + source=f"{SPEC_BASE_URL}/server/tools#resource-links", |
| 101 | + behavior="A tool result can carry a resource_link content block referencing a resource by URI.", |
| 102 | + ), |
| 103 | + "tools:call:content:embedded-resource": Requirement( |
| 104 | + source=f"{SPEC_BASE_URL}/server/tools#embedded-resources", |
| 105 | + behavior="A tool result can carry an embedded resource with full text or blob contents.", |
| 106 | + ), |
| 107 | + "tools:call:content:multiple": Requirement( |
| 108 | + source=f"{SPEC_BASE_URL}/server/tools#calling-tools", |
| 109 | + behavior="A tool result can carry multiple content blocks of different types; order is preserved.", |
| 110 | + ), |
| 111 | + "tools:call:structured-content": Requirement( |
| 112 | + source=f"{SPEC_BASE_URL}/server/tools#structured-content", |
| 113 | + behavior="A tool result can carry structuredContent alongside content; the client receives both.", |
| 114 | + ), |
| 115 | + "tools:call:is-error": Requirement( |
| 116 | + source=f"{SPEC_BASE_URL}/server/tools#error-handling", |
| 117 | + behavior=( |
| 118 | + "A tool execution failure is returned as a result with isError true and the failure described " |
| 119 | + "in content, not as a JSON-RPC error." |
| 120 | + ), |
| 121 | + ), |
| 122 | + "tools:call:unknown-name": Requirement( |
| 123 | + source=f"{SPEC_BASE_URL}/server/tools#error-handling", |
| 124 | + behavior="tools/call for a name the server does not recognise returns a JSON-RPC error.", |
| 125 | + ), |
| 126 | + # ═══════════════════════════════════════════════════════════════════════════ |
| 127 | + # Resources |
| 128 | + # ═══════════════════════════════════════════════════════════════════════════ |
| 129 | + "resources:list:basic": Requirement( |
| 130 | + source=f"{SPEC_BASE_URL}/server/resources#listing-resources", |
| 131 | + behavior=( |
| 132 | + "resources/list returns the registered resources with uri, name, and the optional descriptive " |
| 133 | + "fields supplied by the server." |
| 134 | + ), |
| 135 | + ), |
| 136 | + "resources:read:text": Requirement( |
| 137 | + source=f"{SPEC_BASE_URL}/server/resources#reading-resources", |
| 138 | + behavior="resources/read returns text contents carrying uri, mimeType, and the text.", |
| 139 | + ), |
| 140 | + "resources:read:binary": Requirement( |
| 141 | + source=f"{SPEC_BASE_URL}/server/resources#reading-resources", |
| 142 | + behavior="resources/read returns binary contents base64-encoded in blob.", |
| 143 | + ), |
| 144 | + "resources:read:not-found": Requirement( |
| 145 | + source=f"{SPEC_BASE_URL}/server/resources#error-handling", |
| 146 | + behavior="resources/read for an unknown URI returns a JSON-RPC error; the spec reserves -32002 for it.", |
| 147 | + ), |
| 148 | + # ═══════════════════════════════════════════════════════════════════════════ |
| 149 | + # Prompts |
| 150 | + # ═══════════════════════════════════════════════════════════════════════════ |
| 151 | + "prompts:list:basic": Requirement( |
| 152 | + source=f"{SPEC_BASE_URL}/server/prompts#listing-prompts", |
| 153 | + behavior="prompts/list returns the registered prompts with name, description, and argument declarations.", |
| 154 | + ), |
| 155 | + "prompts:get:arguments": Requirement( |
| 156 | + source=f"{SPEC_BASE_URL}/server/prompts#getting-a-prompt", |
| 157 | + behavior="prompts/get delivers the supplied arguments to the prompt handler and returns its messages.", |
| 158 | + ), |
| 159 | + "prompts:get:multi-message": Requirement( |
| 160 | + source=f"{SPEC_BASE_URL}/server/prompts#getting-a-prompt", |
| 161 | + behavior="A prompt can return multiple messages mixing user and assistant roles; order is preserved.", |
| 162 | + ), |
| 163 | + "prompts:get:unknown-name": Requirement( |
| 164 | + source=f"{SPEC_BASE_URL}/server/prompts#error-handling", |
| 165 | + behavior="prompts/get for an unknown prompt name returns a JSON-RPC error.", |
| 166 | + ), |
| 167 | + # ═══════════════════════════════════════════════════════════════════════════ |
| 168 | + # MCPServer behavioural guarantees (not spec-mandated) |
| 169 | + # ═══════════════════════════════════════════════════════════════════════════ |
| 170 | + "mcpserver:tools:handler-exception": Requirement( |
| 171 | + source="sdk", |
| 172 | + behavior=( |
| 173 | + "An exception raised by a tool function (ToolError or otherwise) is caught and returned as a " |
| 174 | + "tool result with isError true and the failure text in content; it does not become a JSON-RPC error." |
| 175 | + ), |
| 176 | + ), |
| 177 | + "mcpserver:tools:unknown-name": Requirement( |
| 178 | + source="sdk", |
| 179 | + behavior="Calling a tool name that was never registered returns a tool result with isError true.", |
| 180 | + divergence=Divergence( |
| 181 | + note=( |
| 182 | + "The spec classifies unknown tools as a protocol error (its example uses -32602 Invalid " |
| 183 | + "params); MCPServer reports a tool execution error instead. The low-level path follows the " |
| 184 | + "spec example (see tools:call:unknown-name)." |
| 185 | + ), |
| 186 | + ), |
| 187 | + ), |
| 188 | +} |
| 189 | + |
| 190 | + |
| 191 | +def requirement(requirement_id: str) -> Callable[[_TestFn], _TestFn]: |
| 192 | + """Mark a test as exercising a requirement from :data:`REQUIREMENTS`. |
| 193 | +
|
| 194 | + Applies the `requirement` pytest marker and records the coverage link checked by |
| 195 | + `test_coverage.py`. Unknown IDs fail at import time so a typo surfaces as a collection |
| 196 | + error on the offending test, not as a missing-coverage report later. |
| 197 | + """ |
| 198 | + if requirement_id not in REQUIREMENTS: |
| 199 | + raise KeyError(f"Unknown requirement id {requirement_id!r}: add it to REQUIREMENTS in {__name__}") |
| 200 | + |
| 201 | + def apply(test_fn: _TestFn) -> _TestFn: |
| 202 | + covered_by(requirement_id).append(f"{test_fn.__module__}.{test_fn.__qualname__}") |
| 203 | + return pytest.mark.requirement(requirement_id)(test_fn) |
| 204 | + |
| 205 | + return apply |
| 206 | + |
| 207 | + |
| 208 | +_COVERAGE: dict[str, list[str]] = {} |
| 209 | + |
| 210 | + |
| 211 | +def covered_by(requirement_id: str) -> list[str]: |
| 212 | + """Return the (mutable) list of test names recorded as exercising `requirement_id`.""" |
| 213 | + return _COVERAGE.setdefault(requirement_id, []) |
0 commit comments