diff --git a/harness/src/sentinel/catalog/deprecations.py b/harness/src/sentinel/catalog/deprecations.py index 380f8d4..291f3a5 100644 --- a/harness/src/sentinel/catalog/deprecations.py +++ b/harness/src/sentinel/catalog/deprecations.py @@ -200,7 +200,7 @@ def months_remaining(self, detection: Detection) -> int | None: id="roots", name="Roots", replacement="explicit tool arguments naming the paths a tool may touch", - citation=f"{SPEC_BASE}/changelog#sep-2577-roots-sampling-logging", + citation=f"{SPEC_BASE}/changelog#deprecated", sep="SEP-2577", deprecated_on=date(2026, 7, 28), removal=_SEP_2577, @@ -214,7 +214,7 @@ def months_remaining(self, detection: Detection) -> int | None: id="sampling", name="Sampling", replacement="multi round-trip requests (resultType: input_required)", - citation=f"{SPEC_BASE}/changelog#sep-2577-roots-sampling-logging", + citation=f"{SPEC_BASE}/changelog#deprecated", sep="SEP-2577", deprecated_on=date(2026, 7, 28), removal=_SEP_2577, @@ -227,7 +227,7 @@ def months_remaining(self, detection: Detection) -> int | None: id="logging", name="Logging", replacement="_meta.io.modelcontextprotocol/logLevel, set per request", - citation=f"{SPEC_BASE}/changelog#sep-2577-roots-sampling-logging", + citation=f"{SPEC_BASE}/changelog#deprecated", sep="SEP-2577", deprecated_on=date(2026, 7, 28), removal=_SEP_2577, @@ -240,7 +240,7 @@ def months_remaining(self, detection: Detection) -> int | None: id="http-sse", name="HTTP+SSE transport", replacement="Streamable HTTP", - citation=f"{SPEC_BASE}/basic/transports#streamable-http", + citation=f"{SPEC_BASE}/basic/transports/streamable-http#backward-compatibility", sep="SEP-2596", deprecated_on=date(2025, 3, 26), # No date, and no way to ask for one. SEP-2596 has not reached Final, @@ -270,7 +270,7 @@ def months_remaining(self, detection: Detection) -> int | None: id="include-context", name='includeContext: "thisServer" / "allServers"', replacement="explicit arguments carrying whatever context the call needs", - citation=f"{SPEC_BASE}/changelog#includecontext", + citation=f"{SPEC_BASE}/changelog#deprecated", sep="SEP-2596", deprecated_on=date(2025, 11, 25), # The registry says "Follows Sampling (SEP-2577)" — not a date of its diff --git a/harness/src/sentinel/catalog/must/discovery.py b/harness/src/sentinel/catalog/must/discovery.py index efc21fa..472a093 100644 --- a/harness/src/sentinel/catalog/must/discovery.py +++ b/harness/src/sentinel/catalog/must/discovery.py @@ -19,14 +19,14 @@ from sentinel.probe.client import KEY_SERVER_INFO, Probe CHANGELOG = f"{SPEC_BASE}/changelog" -LIFECYCLE = f"{SPEC_BASE}/basic/lifecycle" +LIFECYCLE = f"{SPEC_BASE}/basic/versioning" @rule( id="MCP/2026-07-28/MUST/discover-implemented", title="server/discover is implemented", severity=Severity.MUST, - citation=f"{CHANGELOG}#server-discover-replaces-initialize", + citation=f"{CHANGELOG}#major-changes", verifiability=Verifiability.BLACK_BOX, remediation=( "Implement server/discover. It replaced the initialize handshake in this revision " @@ -61,7 +61,7 @@ def discover_implemented(probe: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/discover-without-negotiated-version", title="server/discover answers without a negotiated protocol version", severity=Severity.MUST, - citation=f"{LIFECYCLE}#version-negotiation", + citation=f"{LIFECYCLE}#protocol-version-negotiation", verifiability=Verifiability.BLACK_BOX, remediation=( "Exempt server/discover from version negotiation. Its purpose is to let a client " @@ -99,7 +99,7 @@ def discover_without_version(probe: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/discover-reports-supported-versions", title="server/discover reports supportedVersions", severity=Severity.MUST, - citation=f"{LIFECYCLE}#version-negotiation", + citation=f"{LIFECYCLE}#protocol-version-negotiation", verifiability=Verifiability.BLACK_BOX, remediation=( "Include a non-empty supportedVersions array in the server/discover result. " @@ -125,7 +125,7 @@ def discover_reports_versions(probe: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/discover-reports-server-info", title="server/discover identifies the server", severity=Severity.MUST, - citation=f"{LIFECYCLE}#server-identity", + citation=f"{SPEC_BASE}/server/discover#response", verifiability=Verifiability.BLACK_BOX, remediation=( "Report serverInfo with at least a name from server/discover. Clients key cache " @@ -158,7 +158,7 @@ def discover_reports_server_info(probe: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/discover-reports-capabilities", title="server/discover reports capabilities", severity=Severity.MUST, - citation=f"{LIFECYCLE}#capabilities", + citation=f"{SPEC_BASE}/server/discover#response", verifiability=Verifiability.BLACK_BOX, remediation=( "Report a capabilities object from server/discover. A client cannot tell an " @@ -183,7 +183,7 @@ def discover_reports_capabilities(probe: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/unsupported-version-rejected", title="An unsupported protocol version is rejected with -32022", severity=Severity.MUST, - citation=f"{LIFECYCLE}#version-negotiation", + citation=f"{LIFECYCLE}#protocol-version-negotiation", verifiability=Verifiability.BLACK_BOX, remediation=( "Return -32022 UnsupportedProtocolVersion, with supportedVersions in the error " @@ -214,7 +214,7 @@ def unsupported_version_rejected(probe: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/list-changed-advertised-truthfully", title="A declared listChanged capability is backed by subscriptions/listen", severity=Severity.MUST, - citation=f"{CHANGELOG}#subscriptions", + citation=f"{CHANGELOG}#major-changes", verifiability=Verifiability.BLACK_BOX, remediation=( "Advertise listChanged: false unless subscriptions/listen is implemented. A client " @@ -279,7 +279,7 @@ def list_changed_is_truthful(probe: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/initialize-removed", title="The initialize handshake is gone", severity=Severity.MUST, - citation=f"{CHANGELOG}#server-discover-replaces-initialize", + citation=f"{CHANGELOG}#major-changes", verifiability=Verifiability.BLACK_BOX, remediation=( "Remove the initialize method and answer method-not-found for it. This revision is " @@ -315,7 +315,7 @@ def _removed_method_rule(method: str, slug: str, why: str, replacement: str) -> id=f"MCP/2026-07-28/MUST/{slug}", title=f"{method} is removed", severity=Severity.MUST, - citation=f"{CHANGELOG}#removed-methods", + citation=f"{CHANGELOG}#major-changes", verifiability=Verifiability.BLACK_BOX, remediation=( f"Stop serving {method}; it was removed in 2026-07-28. {why} " diff --git a/harness/src/sentinel/catalog/must/envelope.py b/harness/src/sentinel/catalog/must/envelope.py index 2ea3f03..17dab71 100644 --- a/harness/src/sentinel/catalog/must/envelope.py +++ b/harness/src/sentinel/catalog/must/envelope.py @@ -26,7 +26,7 @@ id="MCP/2026-07-28/MUST/result-type-present", title="Every result carries a non-empty resultType", severity=Severity.MUST, - citation=f"{BASIC}/index#result-envelope", + citation=f"{BASIC}/index#resulttype", verifiability=Verifiability.BLACK_BOX, remediation=( "Set resultType on every result — 'complete', or 'input_required' when the call " @@ -60,7 +60,7 @@ def result_type_present(probe: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/server-info-echoed", title="Every result echoes serverInfo", severity=Severity.MUST, - citation=f"{BASIC}/index#result-envelope", + citation=f"{BASIC}/index#resulttype", verifiability=Verifiability.BLACK_BOX, remediation=( "Echo io.modelcontextprotocol/serverInfo in each result's _meta. Clients key cache " @@ -83,7 +83,7 @@ def server_info_echoed_deprecated(probe: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/cacheable-results-carry-ttl", title="Every list and read result carries ttlMs", severity=Severity.MUST, - citation=f"{CHANGELOG}#cacheableresult-is-required", + citation=f"{CHANGELOG}#minor-changes", verifiability=Verifiability.BLACK_BOX, remediation=( "Add ttlMs to every list and read result. CacheableResult became required in this " @@ -117,7 +117,7 @@ def cacheable_ttl(probe: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/cacheable-results-carry-scope", title="Every list and read result carries cacheScope", severity=Severity.MUST, - citation=f"{CHANGELOG}#cacheableresult-is-required", + citation=f"{CHANGELOG}#minor-changes", verifiability=Verifiability.BLACK_BOX, remediation=( "Add cacheScope ('private' or 'public') to every list and read result. Choose it " @@ -157,7 +157,7 @@ def cacheable_scope(probe: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/tools-list-is-deterministic", title="tools/list is byte-stable across repeated calls", severity=Severity.MUST, - citation=f"{CHANGELOG}#deterministic-tool-ordering", + citation=f"{CHANGELOG}#minor-changes", verifiability=Verifiability.BLACK_BOX, remediation=( "Build the tool manifest once, sort it by name byte-wise, and serve the " @@ -180,7 +180,7 @@ def tools_list_deterministic_deprecated(probe: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/tools-declare-input-schema", title="Every advertised tool declares an input schema", severity=Severity.MUST, - citation=f"{BASIC}/index#tools", + citation=f"{SPEC_BASE}/server/tools#listing-tools", verifiability=Verifiability.BLACK_BOX, remediation=( "Give every tool an inputSchema. A model calling a tool with no schema has to " @@ -215,7 +215,7 @@ def tools_declare_schema(probe: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/tools-are-named", title="Every advertised tool has a name", severity=Severity.MUST, - citation=f"{BASIC}/index#tools", + citation=f"{SPEC_BASE}/server/tools#listing-tools", verifiability=Verifiability.BLACK_BOX, remediation=( "Give every tool a non-empty name. It is the only handle a client has for calling " diff --git a/harness/src/sentinel/catalog/must/headers.py b/harness/src/sentinel/catalog/must/headers.py index 06581f0..37f8aa8 100644 --- a/harness/src/sentinel/catalog/must/headers.py +++ b/harness/src/sentinel/catalog/must/headers.py @@ -43,7 +43,7 @@ def _named_tool(probe: Probe) -> str | None: id="MCP/2026-07-28/MUST/mcp-method-header-required", title="Mcp-Method is required on Streamable HTTP POST", severity=Severity.MUST, - citation=f"{TRANSPORT}#streamable-http", + citation=f"{TRANSPORT}#request-metadata", verifiability=Verifiability.BLACK_BOX, remediation=( "Reject a POST with no Mcp-Method header. The header exists so a gateway or WAF " @@ -90,7 +90,7 @@ def mcp_method_required(probe: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/mcp-name-header-required", title="Mcp-Name is required on a tools/call, resources/read or prompts/get POST", severity=Severity.MUST, - citation=f"{TRANSPORT}#streamable-http", + citation=f"{TRANSPORT}#request-metadata", verifiability=Verifiability.BLACK_BOX, remediation=( "Reject a tools/call, resources/read or prompts/get with no Mcp-Name header. " @@ -150,7 +150,7 @@ def mcp_name_required(probe: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/mcp-name-not-required-where-undefined", title="Mcp-Name is not demanded on a method the header table does not define it for", severity=Severity.MUST, - citation=f"{TRANSPORT}#streamable-http", + citation=f"{TRANSPORT}#request-metadata", verifiability=Verifiability.BLACK_BOX, introduced_in="0.2.0", remediation=( @@ -211,7 +211,7 @@ def mcp_name_not_required_where_undefined(probe: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/header-body-mismatch-rejected", title="A header disagreeing with the body is rejected with -32020", severity=Severity.MUST, - citation=f"{TRANSPORT}#header-contract", + citation=f"{TRANSPORT}#request-metadata", verifiability=Verifiability.BLACK_BOX, remediation=( "Compare Mcp-Method and Mcp-Name against the JSON-RPC body and return -32020 " @@ -262,7 +262,7 @@ def header_mismatch_rejected(probe: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/resource-not-found-is-invalid-params", title="Resource not found is -32602, not -32002", severity=Severity.MUST, - citation=f"{CHANGELOG}#error-code-reallocation", + citation=f"{ERRORS}", verifiability=Verifiability.BLACK_BOX, remediation=( "Return -32602 InvalidParams for a resource that does not exist. It moved from " diff --git a/harness/src/sentinel/catalog/must/meta.py b/harness/src/sentinel/catalog/must/meta.py index f4e309c..8f68ec6 100644 --- a/harness/src/sentinel/catalog/must/meta.py +++ b/harness/src/sentinel/catalog/must/meta.py @@ -46,7 +46,7 @@ def _rejected_as_invalid_params(response: RawResponse, what: str) -> RuleResult: id="MCP/2026-07-28/MUST/missing-client-capabilities-rejected", title="A request without clientCapabilities is rejected with -32602 and HTTP 400", severity=Severity.MUST, - citation=f"{BASIC}#meta", + citation=f"{BASIC}#_meta", verifiability=Verifiability.BLACK_BOX, remediation=( "Reject a request whose _meta omits io.modelcontextprotocol/clientCapabilities " @@ -68,7 +68,7 @@ def missing_client_capabilities_rejected(probe: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/missing-protocol-version-rejected", title="A request without a declared protocol version is rejected with -32602 and HTTP 400", severity=Severity.MUST, - citation=f"{BASIC}#meta", + citation=f"{BASIC}#_meta", verifiability=Verifiability.BLACK_BOX, remediation=( "Reject a request whose _meta omits io.modelcontextprotocol/protocolVersion with " @@ -92,7 +92,7 @@ def missing_protocol_version_rejected(probe: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/missing-capability-error-shape", title="A -32021 error names the capabilities it needed", severity=Severity.MUST, - citation=f"{BASIC}#meta", + citation=f"{BASIC}#_meta", verifiability=Verifiability.BLACK_BOX, remediation=( "When returning -32021 MissingRequiredClientCapability, populate " diff --git a/harness/src/sentinel/catalog/must/unverifiable.py b/harness/src/sentinel/catalog/must/unverifiable.py index 45a1407..8a4ebdf 100644 --- a/harness/src/sentinel/catalog/must/unverifiable.py +++ b/harness/src/sentinel/catalog/must/unverifiable.py @@ -28,7 +28,7 @@ id="MCP/2026-07-28/MUST/token-audience-validated", title="The server rejects tokens not issued for it", severity=Severity.MUST, - citation=f"{SECURITY}#token-audience-binding", + citation=f"{SECURITY}#token-passthrough", verifiability=Verifiability.UNVERIFIABLE, remediation=( "Check that the token's `aud` claim contains this server's identifier, by exact " @@ -75,7 +75,7 @@ def no_token_passthrough(_: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/handle-possession-is-not-authentication", title="Server-minted handles are re-verified against the caller", severity=Severity.MUST, - citation=f"{SECURITY}#state-handles", + citation=f"{SECURITY}#state-handle-hijacking", verifiability=Verifiability.UNVERIFIABLE, remediation=( "Re-verify the principal and tenant on EVERY handle resolution, against the " @@ -98,7 +98,7 @@ def handle_possession_not_auth(_: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/mrtr-retries-are-idempotent", title="A duplicate MRTR retry performs no additional side effect", severity=Severity.MUST, - citation=f"{SPEC_BASE}/basic/patterns/mrtr#idempotency", + citation=f"{SPEC_BASE}/basic/patterns/mrtr#basic-workflow", verifiability=Verifiability.UNVERIFIABLE, remediation=( "Record the result when a flow is consumed and replay it verbatim on any duplicate " @@ -121,7 +121,7 @@ def mrtr_idempotent(_: Probe) -> RuleResult: id="MCP/2026-07-28/MUST/invocations-are-audited", title="Every tool invocation is recorded in an append-only log", severity=Severity.MUST, - citation=f"{SECURITY}#audit-logging", + citation=f"{SECURITY}#token-passthrough", verifiability=Verifiability.UNVERIFIABLE, remediation=( "Write an append-only, hash-chained row for every invocation, and fail the " diff --git a/harness/src/sentinel/catalog/should/envelope.py b/harness/src/sentinel/catalog/should/envelope.py index 84680fa..1cdf4ee 100644 --- a/harness/src/sentinel/catalog/should/envelope.py +++ b/harness/src/sentinel/catalog/should/envelope.py @@ -17,7 +17,7 @@ id="MCP/2026-07-28/SHOULD/server-info-echoed", title="Every result echoes serverInfo", severity=Severity.SHOULD, - citation=f"{SPEC_BASE}/basic/index#meta", + citation=f"{SPEC_BASE}/basic/index#_meta", verifiability=Verifiability.BLACK_BOX, remediation=( "Echo io.modelcontextprotocol/serverInfo in each result's _meta. The spec marks it " diff --git a/harness/src/sentinel/catalog/should/http.py b/harness/src/sentinel/catalog/should/http.py index 4623dcc..aeeb8bf 100644 --- a/harness/src/sentinel/catalog/should/http.py +++ b/harness/src/sentinel/catalog/should/http.py @@ -27,7 +27,7 @@ id="MCP/2026-07-28/SHOULD/get-delete-405", title="A bare GET or DELETE on the endpoint answers 405 Method Not Allowed", severity=Severity.SHOULD, - citation=f"{STREAMABLE}#backwards-compatibility", + citation=f"{STREAMABLE}#backward-compatibility", verifiability=Verifiability.BLACK_BOX, remediation=( "Answer 405 Method Not Allowed to a GET or a DELETE on the MCP endpoint. Both " diff --git a/harness/src/sentinel/catalog/should/ordering.py b/harness/src/sentinel/catalog/should/ordering.py index bfdb07e..0225e1e 100644 --- a/harness/src/sentinel/catalog/should/ordering.py +++ b/harness/src/sentinel/catalog/should/ordering.py @@ -19,7 +19,7 @@ id="MCP/2026-07-28/SHOULD/tools-sorted-by-name", title="tools/list returns tools in byte-wise name order", severity=Severity.SHOULD, - citation=f"{CHANGELOG}#deterministic-tool-ordering", + citation=f"{CHANGELOG}#minor-changes", verifiability=Verifiability.BLACK_BOX, remediation=( "Sort tools by name byte-wise (not case-insensitively, not by locale collation). " @@ -43,7 +43,7 @@ def tools_sorted(probe: Probe) -> RuleResult: id="MCP/2026-07-28/SHOULD/tools-have-descriptions", title="Every tool has a description", severity=Severity.SHOULD, - citation=f"{SPEC_BASE}/basic/index#tools", + citation=f"{SPEC_BASE}/server/tools#listing-tools", verifiability=Verifiability.BLACK_BOX, remediation=( "Describe every tool. The description is what a model reads to choose between " diff --git a/tests/harness/test_citations.py b/tests/harness/test_citations.py new file mode 100644 index 0000000..7cc1441 --- /dev/null +++ b/tests/harness/test_citations.py @@ -0,0 +1,58 @@ +"""Every spec citation must point at a page and anchor that exist. + +Verified against the published 2026-07-28 pages (headings fetched 2026-10-03). +A citation to an anchor that does not exist sends the reader of a report to the +top of a page and implies the spec says something it does not, so the allowlist +below is the contract: add a page/anchor here only after checking the live spec. +""" + +from __future__ import annotations + +from urllib.parse import urlparse + +import pytest + +from sentinel.catalog.base import REGISTRY, SPEC_BASE, Namespace + +pytestmark = pytest.mark.unit + +#: page path (relative to the revision) -> anchors known to exist ("" = page only). +KNOWN: dict[str, set[str]] = { + "changelog": {"major-changes", "minor-changes", "deprecated"}, + "basic/index": {"error-codes", "_meta", "resulttype", "statelessness"}, + "basic/versioning": {"protocol-version-negotiation", "extension-negotiation"}, + "basic/transports": {"request-metadata", "backward-compatibility"}, + "basic/transports/streamable-http": { + "security-endpoint", + "sending-messages", + "request-metadata", + "protocol-version-header", + "standard-request-headers", + "server-validation", + "backward-compatibility", + }, + "basic/patterns/mrtr": {"basic-workflow", "error-handling"}, + "basic/authorization": {"client-registration"}, + "basic/security_best_practices": { + "token-passthrough", + "state-handle-hijacking", + }, + "server/tools": {"capabilities", "listing-tools"}, + "server/discover": {"response", "data-types"}, +} + + +def _spec_rules() -> list[tuple[str, str]]: + return [(r.id, r.citation) for r in REGISTRY if r.namespace is Namespace.MCP] + + +@pytest.mark.parametrize(("rule_id", "citation"), _spec_rules()) +def test_citation_resolves_to_a_real_page_and_anchor(rule_id: str, citation: str) -> None: + assert citation.startswith(SPEC_BASE + "/"), f"{rule_id}: {citation}" + parsed = urlparse(citation) + page = parsed.path.removeprefix(urlparse(SPEC_BASE).path + "/") + assert page in KNOWN, f"{rule_id}: unknown page {page!r}" + if parsed.fragment: + assert parsed.fragment in KNOWN[page], ( + f"{rule_id}: anchor #{parsed.fragment} not on {page}; known: {sorted(KNOWN[page])}" + )