Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions harness/src/sentinel/catalog/deprecations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
20 changes: 10 additions & 10 deletions harness/src/sentinel/catalog/must/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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. "
Expand All @@ -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 "
Expand Down Expand Up @@ -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 "
Expand All @@ -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 "
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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} "
Expand Down
14 changes: 7 additions & 7 deletions harness/src/sentinel/catalog/must/envelope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down Expand Up @@ -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 "
Expand All @@ -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 "
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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 "
Expand All @@ -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 "
Expand Down Expand Up @@ -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 "
Expand Down
10 changes: 5 additions & 5 deletions harness/src/sentinel/catalog/must/headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down Expand Up @@ -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. "
Expand Down Expand Up @@ -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=(
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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 "
Expand Down
6 changes: 3 additions & 3 deletions harness/src/sentinel/catalog/must/meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand All @@ -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 "
Expand All @@ -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 "
Expand Down
8 changes: 4 additions & 4 deletions harness/src/sentinel/catalog/must/unverifiable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down Expand Up @@ -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 "
Expand All @@ -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 "
Expand All @@ -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 "
Expand Down
2 changes: 1 addition & 1 deletion harness/src/sentinel/catalog/should/envelope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down
2 changes: 1 addition & 1 deletion harness/src/sentinel/catalog/should/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down
4 changes: 2 additions & 2 deletions harness/src/sentinel/catalog/should/ordering.py
Original file line number Diff line number Diff line change
Expand Up @@ -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). "
Expand All @@ -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 "
Expand Down
58 changes: 58 additions & 0 deletions tests/harness/test_citations.py
Original file line number Diff line number Diff line change
@@ -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])}"
)
Loading