From 90bf7f282f4fb2cb875251de939c539d2a94b50b Mon Sep 17 00:00:00 2001 From: Noah Ingwers <98993329+noah-ing@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:52:34 -0500 Subject: [PATCH 1/5] examples: bind MCP retries to retained declaration snapshots Signed-off-by: Noah Ingwers <98993329+noah-ing@users.noreply.github.com> --- CHANGELOG.md | 5 + examples/README.md | 4 + examples/mcp-retry/README.md | 98 +++++ examples/mcp-retry/gen_mcp_retry.py | 222 +++++++++++ examples/mcp-retry/packet.json | 290 +++++++++++++++ examples/mcp-retry/verification-inputs.json | 8 + spec/mcp-profile-v0.3-draft.md | 4 + tests/test_adequacy_all_sets.py | 4 + tests/test_mcp_retry_example.py | 385 ++++++++++++++++++++ 9 files changed, 1020 insertions(+) create mode 100644 examples/mcp-retry/README.md create mode 100644 examples/mcp-retry/gen_mcp_retry.py create mode 100644 examples/mcp-retry/packet.json create mode 100644 examples/mcp-retry/verification-inputs.json create mode 100644 tests/test_mcp_retry_example.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 886a9ca0..34102797 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. ## [Unreleased] +- Add an informative MCP lost-response/retry example with full paginated declaration + snapshots, signed transcript commitments and causal evidence checks for #324. + Keep the first execution outcome unknown after a reported successful retry. + Uses an example-local format with a v0.2 record, not a v0.3 implementation. + ## [0.11.0] - 2026-09-25 - **Breaking:** `TraceSandboxAdapter` and `TraceAGTAdapter` require `enforcement_mode`; diff --git a/examples/README.md b/examples/README.md index b9922e5a..e0902c13 100644 --- a/examples/README.md +++ b/examples/README.md @@ -49,6 +49,10 @@ checking any archived record, including these. verifies exactly as the pass does, unresolvable, and an issuer whose key the relying party does not hold. Every digest recomputes; see that directory's README and `docs/references-registry.md`. +- [`mcp-retry/`](mcp-retry/README.md): one signed v0.2 record binding two synthetic + MCP attempts and full paginated declaration snapshots. A lost response stays + unknown after retry success; an uncalled tool changes between captures. + Informative local format, not an implementation of the proposed v0.3 profile. The schema sets `additionalProperties: false`, so examples must not carry non-schema keys such as `_comment`. Keep descriptive notes in this file. diff --git a/examples/mcp-retry/README.md b/examples/mcp-retry/README.md new file mode 100644 index 00000000..8abc50b6 --- /dev/null +++ b/examples/mcp-retry/README.md @@ -0,0 +1,98 @@ +# Lost response, retry, changed declarations + +An informative, synthetic worked example for [issue #324][issue] and the +[proposed MCP profile][draft]. It uses an ordinary signed **TRACE v0.2** record. +It does not implement or claim conformance to TRACE v0.3, and changes no SDK +acceptance rule. The small local format below is an example, not a profile proposal. + +## Read the trace + +| Step | Retained producer observation | What it supports | +|---|---|---| +| Discover | Two ordered `tools/list` result pages, through the terminal page | A reported complete traversal, not an atomic server catalog | +| Attempt 1 | Request ID `1`; response stream lost | Execution outcome unknown, not proof of non-execution | +| Discover again | Two new pages; an **uncalled** tool's metadata changed | A different snapshot, not a replacement for the first capture | +| Attempt 2 | New request ID `2`, linked to attempt 1; a successful response recorded | Producer-reported response only; attempt 1 stays unknown | + +The selected `inventory.lookup` declaration and its fictional arguments do not +change. Both attempts share an OTel `traceparent`, but have distinct producer +attempt IDs and request contexts. Correlation is not authorization or causality. +`call_count: 2` counts recorded attempts, not completed business operations. + +The [MCP 2026-07-28 changes][mcp] require a new request ID when reissuing after a +lost response stream. This packet illustrates that boundary; it does **not** run +an MCP client, server, network failure, model, policy engine or TPM. + +## Files and exact commitments + +- `packet.json`: signed record, retained transcript and snapshots keyed by digest. +- `verification-inputs.json`: separately configured fixture trust key and fixed + evaluation time. Never derive a trusted key from an incoming packet. +- `gen_mcp_retry.py`: deterministic generator. Its public test seed is intentionally + not a secret and establishes no real-world signer identity. +- [`test_mcp_retry_example.py`][tests]: executable, test-local evidence checks and + causal negative controls. This is not a production MCP verifier. + +The signature covers the record through the existing `sign_record()` / +`verify_record()` APIs. The signed `tool_transcript.hash` is SHA-256 of the +canonical **entire transcript object**. Each attempt inside that object binds its +declaration digest and canonicalization identifier. A snapshot digest is SHA-256 +of the canonical **entire snapshot object**, including ordered pages, request +cursors, cache metadata, observation flags, capture identity, and every declaration +member, not a selected-field catalog projection. + +The local canonicalization identifier is `example-only/jcs-safe-integers-v1`: +UTF-8 RFC 8785 using `rfc8785`, duplicate JSON members rejected before conversion, +all floating-point numbers refused, integers restricted to the inclusive range +`[-9007199254740991, 9007199254740991]`. Booleans and null are retained. Array order +is preserved. This deliberately does not support all numbers allowed in MCP +schemas; no rounding, coercion or dropping unsupported members occurs. + +The committed `format` members distinguish transcript and snapshot hash domains. +The `example-only/...` names are local constants, **not registered profile IDs**. +Pretty-print whitespace in the fixture is not committed; the canonical JSON +values are. These are retained synthetic JSON result objects, not captured HTTP +bytes. Headers, connection identity, server authentication and transport timing +are not evidenced. Every result page is retained in full for this synthetic +capture; page completion does not establish a single atomic server revision. + +## Reproduce + +From the repository root, with its development environment installed: + +```bash +python examples/mcp-retry/gen_mcp_retry.py +pytest tests/test_mcp_retry_example.py tests/test_generators_reproduce_fixtures.py +git diff --exit-code -- examples/mcp-retry/packet.json examples/mcp-retry/verification-inputs.json +``` + +The existing generator test deletes the example JSON from a temporary checkout +and regenerates it before comparing bytes. Negative controls use copies of the +committed packet, not unchecked generator verdicts. The tests separately check +record authentication, transcript integrity and snapshot availability/integrity. +Missing retained evidence is unavailable, not a successful comparison; changed +retained evidence is a mismatch. Neither changes the narrower fact that an +otherwise intact record signature verifies. + +## Assurance ceiling and remaining work + +The packet authenticates a fixture producer's commitments. It does not prove +truthful collection, what an agent actually saw, a complete execution history, +server identity, actual execution, authorization, safe behavior, runtime integrity, +hardware provenance, or exactly-once execution. `policy.enforcement_mode` is +explicitly `declared`; `appraisal.status` is `none`. Runtime, model and build fields +are synthetic placeholders, not attestations or SLSA evidence. No revocation +check is performed. The two evidence sources remain separate: a producer-signed +response observation is not a server-signed response. + +This covers the requested small lost-response/retry/changed-declarations example, +not every acceptance case in draft section 6. Cached reuse/freshness policy, +notifications during collection, resource limits, multi-round-trip continuations, +independent server proofs and confidentiality/retention remain future work. +Wire schema, canonicalization choices, resource bounds and interoperable verifier +rules still require the draft's normal adoption process. + +[issue]: https://github.com/agentrust-io/trace-spec/issues/324 +[draft]: ../../spec/mcp-profile-v0.3-draft.md +[mcp]: https://modelcontextprotocol.io/specification/2026-07-28/changelog +[tests]: ../../tests/test_mcp_retry_example.py diff --git a/examples/mcp-retry/gen_mcp_retry.py b/examples/mcp-retry/gen_mcp_retry.py new file mode 100644 index 00000000..fbf01797 --- /dev/null +++ b/examples/mcp-retry/gen_mcp_retry.py @@ -0,0 +1,222 @@ +"""Generate an informative, synthetic MCP retry packet, not a v0.3 profile. + +The public seed is fixture material, never a production signing identity. +Run from the repository root after installing its development dependencies. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +import rfc8785 +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from agentrust_trace import key_to_jwk, sign_record + +CANONICALIZATION = "example-only/jcs-safe-integers-v1" +TRANSCRIPT_FORMAT = "example-only/mcp-retry-transcript-v1" +SNAPSHOT_FORMAT = "example-only/mcp-declarations-v1" +NOW = 1790294400 +SERVER = "https://inventory.example.org/mcp" +PROTOCOL = "2026-07-28" + + +def canonical_bytes(value: Any) -> bytes: + """Example-local subset: JCS, with all floats and unsafe integers refused.""" + if isinstance(value, float) or (type(value) is int and abs(value) > 9007199254740991): + raise ValueError("unsupported numeric value in example canonicalization") + if isinstance(value, dict): + for item in value.values(): + canonical_bytes(item) + elif isinstance(value, list): + for item in value: + canonical_bytes(item) + return rfc8785.dumps(value) + + +def load_json(text: str) -> Any: + """Do not silently collapse duplicate members before making commitments.""" + + def unique(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON member") + result[key] = value + return result + + value = json.loads(text, object_pairs_hook=unique) + canonical_bytes(value) + return value + + +def digest(value: Any) -> str: + return "sha256:" + hashlib.sha256(canonical_bytes(value)).hexdigest() + + +def fixture_key() -> Ed25519PrivateKey: + # Intentionally public and deterministic, following the other example vectors. + seed = hashlib.sha256(b"TRACE mcp-retry example ONLY - not a secret").digest() + return Ed25519PrivateKey.from_private_bytes(seed) + + +def snapshot(capture_id: str) -> dict[str, Any]: + tools = [ + { + "name": "inventory.lookup", + "description": "Look up a fictional stock record.", + "inputSchema": { + "type": "object", + "properties": {"record_id": {"type": "string"}}, + "required": ["record_id"], + "additionalProperties": False, + }, + "outputSchema": { + "type": "object", + "properties": {"stock": {"type": "integer"}}, + "required": ["stock"], + "additionalProperties": False, + }, + "annotations": {"readOnlyHint": True}, + }, + { + "name": "inventory.reserve", + "description": "Uncalled tool, retained in full.", + "inputSchema": {"type": "object"}, + "outputSchema": {"type": "object"}, + "_meta": {"example.org/declaration-revision": "one"}, + }, + ] + return { + "format": SNAPSHOT_FORMAT, + "canonicalization": CANONICALIZATION, + "server_observed": SERVER, + "protocol_revision": PROTOCOL, + "capture_id": capture_id, + "source": "fresh-discovery", + "collection_complete_observed": True, + "catalog_change_observed_during_collection": False, + "catalog_change_observed_before_dispatch": False, + "pages": [ + { + "request_cursor": None, + "result": { + "resultType": "complete", + "tools": [tools[0]], + "nextCursor": "page-2", + "ttlMs": 0, + "cacheScope": "private", + }, + }, + { + "request_cursor": "page-2", + "result": { + "resultType": "complete", + "tools": [tools[1]], + "ttlMs": 0, + "cacheScope": "private", + }, + }, + ], + } + + +def packet() -> dict[str, Any]: + before = snapshot("capture-before-first-dispatch") + after = snapshot("capture-before-retry-dispatch") + # Same selected tool. The changed declaration is deliberately an uncalled tool. + after["pages"][1]["result"]["tools"][0]["_meta"]["example.org/declaration-revision"] = "two" + snapshots = {digest(s): s for s in (before, after)} + attempts = [] + for index, declaration in enumerate((before, after), start=1): + request = { + "jsonrpc": "2.0", + "id": index, + "method": "tools/call", + "params": { + "name": "inventory.lookup", + "arguments": {"record_id": "fictional-7"}, + "_meta": { + "io.modelcontextprotocol/protocolVersion": PROTOCOL, + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": {"name": "example", "version": "1"}, + "traceparent": "00-11111111111111111111111111111111-2222222222222222-01", + }, + }, + } + observation = ( + {"kind": "response_stream_lost"} + if index == 1 + else { + "kind": "response_observed", + "response": { + "jsonrpc": "2.0", + "id": index, + "result": { + "resultType": "complete", + "isError": False, + "content": [{"type": "text", "text": "Fictional stock: 3"}], + "structuredContent": {"stock": 3}, + }, + }, + } + ) + attempts.append( + { + "attempt_id": f"attempt-{index}", + "retry_of": None if index == 1 else "attempt-1", + "request_context": f"http-post-{index}", + "server_observed": SERVER, + "protocol_revision": PROTOCOL, + "request": request, + "declarations": { + "digest": digest(declaration), + "canonicalization": CANONICALIZATION, + }, + "observation": observation, + } + ) + transcript = { + "format": TRANSCRIPT_FORMAT, + "canonicalization": CANONICALIZATION, + "execution_scope": "synthetic-scope-1", + "attempts": attempts, + } + record = sign_record( + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": NOW, + "subject": "spiffe://example.org/agent/synthetic-mcp-producer", + "model": {"provider": "synthetic", "model_id": "no-model-executed"}, + "runtime": {"platform": "software-only", "measurement": digest("synthetic runtime")}, + "policy": { + "bundle_hash": digest({"example_policy": "not evaluated"}), + "enforcement_mode": "declared", + }, + "data_class": "public", + "build_provenance": {"slsa_level": 0, "digest": digest("synthetic build")}, + "appraisal": {"status": "none", "verifier": "https://verifier.example.org"}, + "tool_transcript": {"hash": digest(transcript), "call_count": len(attempts)}, + }, + fixture_key(), + ) + return {"record": record, "transcript": transcript, "snapshots": snapshots} + + +def main() -> None: + directory = Path(__file__).parent + artifacts = { + "packet.json": packet(), + "verification-inputs.json": {"trusted_key": key_to_jwk(fixture_key()), "now": NOW}, + } + for name, value in artifacts.items(): + (directory / name).write_text( + json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + + +if __name__ == "__main__": + main() diff --git a/examples/mcp-retry/packet.json b/examples/mcp-retry/packet.json new file mode 100644 index 00000000..b3b44c66 --- /dev/null +++ b/examples/mcp-retry/packet.json @@ -0,0 +1,290 @@ +{ + "record": { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1790294400, + "subject": "spiffe://example.org/agent/synthetic-mcp-producer", + "model": { + "provider": "synthetic", + "model_id": "no-model-executed" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:ef49ce9e877bdf9b6bcc1cb0b56437b564f2f91d6baf9a54024f04eda3447b35" + }, + "policy": { + "bundle_hash": "sha256:e4a9b5845f1d08eaf5c277fc324a7268c80b87570c0e7dfa7b588d43af6f1f2b", + "enforcement_mode": "declared" + }, + "data_class": "public", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:b4b57759a9248accddb8c61cde857f5d43286984a4456e38145619e69069e6a1" + }, + "appraisal": { + "status": "none", + "verifier": "https://verifier.example.org" + }, + "tool_transcript": { + "hash": "sha256:9e448a4a37c2f6c80cfe5eaaeaa374f1420fdbc34ade7f737d75704b08737226", + "call_count": 2 + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "To0gBmGBWZZHhWaNFEaBEAyR0pBToKLXiKCb2edaCqc" + } + }, + "signature": "n5R1SK2pY98WZcE-nSTovJiwVHD6O6nMWQ_GK_L0VdaIHxhHTwXYQr8-QnEJA2K1a9prtkznQ-V5MKxBvHDuCQ" + }, + "transcript": { + "format": "example-only/mcp-retry-transcript-v1", + "canonicalization": "example-only/jcs-safe-integers-v1", + "execution_scope": "synthetic-scope-1", + "attempts": [ + { + "attempt_id": "attempt-1", + "retry_of": null, + "request_context": "http-post-1", + "server_observed": "https://inventory.example.org/mcp", + "protocol_revision": "2026-07-28", + "request": { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "inventory.lookup", + "arguments": { + "record_id": "fictional-7" + }, + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { + "name": "example", + "version": "1" + }, + "traceparent": "00-11111111111111111111111111111111-2222222222222222-01" + } + } + }, + "declarations": { + "digest": "sha256:93bdb7facd1ee883e13a95a2215b2e676639ae6232d9ae1702d0b17b57302ee3", + "canonicalization": "example-only/jcs-safe-integers-v1" + }, + "observation": { + "kind": "response_stream_lost" + } + }, + { + "attempt_id": "attempt-2", + "retry_of": "attempt-1", + "request_context": "http-post-2", + "server_observed": "https://inventory.example.org/mcp", + "protocol_revision": "2026-07-28", + "request": { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "inventory.lookup", + "arguments": { + "record_id": "fictional-7" + }, + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { + "name": "example", + "version": "1" + }, + "traceparent": "00-11111111111111111111111111111111-2222222222222222-01" + } + } + }, + "declarations": { + "digest": "sha256:a156121db05f2980eb5394aeeca364492cb43f678b4c0d487fe8954b00a7dac1", + "canonicalization": "example-only/jcs-safe-integers-v1" + }, + "observation": { + "kind": "response_observed", + "response": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "resultType": "complete", + "isError": false, + "content": [ + { + "type": "text", + "text": "Fictional stock: 3" + } + ], + "structuredContent": { + "stock": 3 + } + } + } + } + } + ] + }, + "snapshots": { + "sha256:93bdb7facd1ee883e13a95a2215b2e676639ae6232d9ae1702d0b17b57302ee3": { + "format": "example-only/mcp-declarations-v1", + "canonicalization": "example-only/jcs-safe-integers-v1", + "server_observed": "https://inventory.example.org/mcp", + "protocol_revision": "2026-07-28", + "capture_id": "capture-before-first-dispatch", + "source": "fresh-discovery", + "collection_complete_observed": true, + "catalog_change_observed_during_collection": false, + "catalog_change_observed_before_dispatch": false, + "pages": [ + { + "request_cursor": null, + "result": { + "resultType": "complete", + "tools": [ + { + "name": "inventory.lookup", + "description": "Look up a fictional stock record.", + "inputSchema": { + "type": "object", + "properties": { + "record_id": { + "type": "string" + } + }, + "required": [ + "record_id" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": { + "stock": { + "type": "integer" + } + }, + "required": [ + "stock" + ], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": true + } + } + ], + "nextCursor": "page-2", + "ttlMs": 0, + "cacheScope": "private" + } + }, + { + "request_cursor": "page-2", + "result": { + "resultType": "complete", + "tools": [ + { + "name": "inventory.reserve", + "description": "Uncalled tool, retained in full.", + "inputSchema": { + "type": "object" + }, + "outputSchema": { + "type": "object" + }, + "_meta": { + "example.org/declaration-revision": "one" + } + } + ], + "ttlMs": 0, + "cacheScope": "private" + } + } + ] + }, + "sha256:a156121db05f2980eb5394aeeca364492cb43f678b4c0d487fe8954b00a7dac1": { + "format": "example-only/mcp-declarations-v1", + "canonicalization": "example-only/jcs-safe-integers-v1", + "server_observed": "https://inventory.example.org/mcp", + "protocol_revision": "2026-07-28", + "capture_id": "capture-before-retry-dispatch", + "source": "fresh-discovery", + "collection_complete_observed": true, + "catalog_change_observed_during_collection": false, + "catalog_change_observed_before_dispatch": false, + "pages": [ + { + "request_cursor": null, + "result": { + "resultType": "complete", + "tools": [ + { + "name": "inventory.lookup", + "description": "Look up a fictional stock record.", + "inputSchema": { + "type": "object", + "properties": { + "record_id": { + "type": "string" + } + }, + "required": [ + "record_id" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": { + "stock": { + "type": "integer" + } + }, + "required": [ + "stock" + ], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": true + } + } + ], + "nextCursor": "page-2", + "ttlMs": 0, + "cacheScope": "private" + } + }, + { + "request_cursor": "page-2", + "result": { + "resultType": "complete", + "tools": [ + { + "name": "inventory.reserve", + "description": "Uncalled tool, retained in full.", + "inputSchema": { + "type": "object" + }, + "outputSchema": { + "type": "object" + }, + "_meta": { + "example.org/declaration-revision": "two" + } + } + ], + "ttlMs": 0, + "cacheScope": "private" + } + } + ] + } + } +} diff --git a/examples/mcp-retry/verification-inputs.json b/examples/mcp-retry/verification-inputs.json new file mode 100644 index 00000000..551079ad --- /dev/null +++ b/examples/mcp-retry/verification-inputs.json @@ -0,0 +1,8 @@ +{ + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "To0gBmGBWZZHhWaNFEaBEAyR0pBToKLXiKCb2edaCqc" + }, + "now": 1790294400 +} diff --git a/spec/mcp-profile-v0.3-draft.md b/spec/mcp-profile-v0.3-draft.md index 1f09e3a0..5264a904 100644 --- a/spec/mcp-profile-v0.3-draft.md +++ b/spec/mcp-profile-v0.3-draft.md @@ -120,6 +120,10 @@ attempts, and MUST NOT be presented as counts of business operations completed. These are required test designs, not claims of tests already implemented. +The informative [MCP retry example](../examples/mcp-retry/README.md) illustrates a +subset using a signed v0.2 record and an explicitly local evidence format. Its +tests do not implement this proposed profile or settle the choices in section 7. + | Positive control | Negative or boundary case | Required distinction | |---|---|---| | Retained complete snapshot matches the signed commitment | Change an uncalled tool, output schema or extension | Snapshot mismatch | diff --git a/tests/test_adequacy_all_sets.py b/tests/test_adequacy_all_sets.py index e0596d30..8b798b06 100644 --- a/tests/test_adequacy_all_sets.py +++ b/tests/test_adequacy_all_sets.py @@ -236,6 +236,10 @@ def test_the_loader_reads_a_different_set_for_each_name() -> None: # That is the defect these criteria exist to catch, so leaving it in the instrument is # the one place it could not be caught. MEASURED_ELSEWHERE = { + # An informative packet, not a normative accept/reject conformance corpus. + "mcp-retry": "tests/test_mcp_retry_example.py, which separately checks the " + "record signature, full transcript/snapshot commitments, observed " + "pagination and retry outcomes with isolated causal changes", # Not loadable here: every vector verifies, and the outcomes are per-surface # resolvability rather than accept/reject, so `trivially_satisfied_by` would # grade the set as passable by an implementation that accepts everything. diff --git a/tests/test_mcp_retry_example.py b/tests/test_mcp_retry_example.py new file mode 100644 index 00000000..10f822f8 --- /dev/null +++ b/tests/test_mcp_retry_example.py @@ -0,0 +1,385 @@ +"""Assess the retained MCP retry example, not a normative MCP verifier. + +The SDK checks the v0.2 record. The small assessment below checks this example's +local commitments and observations separately. Its outcomes are not conformance +claims, server authentication, execution proof, or complete-history claims. +""" + +from __future__ import annotations + +import hashlib +import runpy +from pathlib import Path +from typing import Any + +import pytest +import rfc8785 +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from agentrust_trace import key_to_jwk, sign_record, verify_record + +DIRECTORY = Path(__file__).resolve().parents[1] / "examples" / "mcp-retry" +GENERATOR = runpy.run_path(str(DIRECTORY / "gen_mcp_retry.py")) +CANONICALIZATION = GENERATOR["CANONICALIZATION"] + + +@pytest.fixture +def packet() -> dict[str, Any]: + return GENERATOR["load_json"]((DIRECTORY / "packet.json").read_text("utf-8")) + + +@pytest.fixture +def inputs() -> dict[str, Any]: + return GENERATOR["load_json"]((DIRECTORY / "verification-inputs.json").read_text("utf-8")) + + +def _require(condition: bool, reason: str) -> None: + if not condition: + raise ValueError(reason) + + +def _same_id(left: Any, right: Any) -> bool: + return type(left) is type(right) and left == right + + +def _verify_record(packet: dict[str, Any], inputs: dict[str, Any]) -> None: + result = verify_record( + packet["record"], + inputs["trusted_key"], + allow_embedded_key=False, + now=inputs["now"], + max_age_seconds=60, + max_future_skew_seconds=0, + ) + assert result.revocation.outcome == "no_check_performed" + + +def _snapshot_status(packet: dict[str, Any], attempt: dict[str, Any]) -> str: + reference = attempt["declarations"] + _require( + reference["canonicalization"] == CANONICALIZATION, "unsupported snapshot canonicalization" + ) + retained = packet.get("snapshots", {}) + commitment = reference["digest"] + if commitment not in retained: + return "unavailable" + snapshot = retained[commitment] + if GENERATOR["digest"](snapshot) != commitment: + return "mismatch" + _require( + snapshot["canonicalization"] == CANONICALIZATION, "unsupported snapshot canonicalization" + ) + _require(snapshot["format"] == GENERATOR["SNAPSHOT_FORMAT"], "unsupported snapshot format") + _require( + snapshot["server_observed"] == attempt["server_observed"] + and snapshot["protocol_revision"] == attempt["protocol_revision"], + "snapshot context differs from attempt", + ) + + # This establishes an observed terminal traversal, not an atomic catalog. + pages = snapshot["pages"] + if not pages or snapshot["collection_complete_observed"] is not True: + return "incomplete" + expected_cursor: str | None = None + seen: set[str | None] = set() + for index, page in enumerate(pages): + cursor = page["request_cursor"] + if not _same_id(cursor, expected_cursor) or cursor in seen: + return "incomplete" + seen.add(cursor) + result = page["result"] + if result["resultType"] != "complete": + return "incomplete" + if "nextCursor" not in result: + return "complete_observed" if index == len(pages) - 1 else "incomplete" + expected_cursor = result["nextCursor"] + if type(expected_cursor) is not str or expected_cursor in seen: + return "incomplete" + return "incomplete" + + +def _assess(packet: dict[str, Any], inputs: dict[str, Any]) -> dict[str, Any]: + """Check only the local example contract; no untrusted network resolution.""" + _verify_record(packet, inputs) + result: dict[str, Any] = {"record": "verified"} + transcript = packet.get("transcript") + if transcript is None: + return {**result, "transcript": "unavailable"} + if GENERATOR["digest"](transcript) != packet["record"]["tool_transcript"]["hash"]: + return {**result, "transcript": "mismatch"} + _require( + transcript["canonicalization"] == CANONICALIZATION, + "unsupported transcript canonicalization", + ) + _require( + transcript["format"] == GENERATOR["TRANSCRIPT_FORMAT"], "unsupported transcript format" + ) + _require( + isinstance(transcript["execution_scope"], str) and bool(transcript["execution_scope"]), + "missing execution scope", + ) + attempts = transcript["attempts"] + _require( + packet["record"]["tool_transcript"]["call_count"] == len(attempts), + "call count differs from recorded attempts", + ) + _require(len(attempts) == 2, "example requires two retained attempts") + prior: dict[str, dict[str, Any]] = {} + request_contexts: set[str] = set() + observations = [] + for index, attempt in enumerate(attempts): + identifier = attempt["attempt_id"] + _require(identifier not in prior, "duplicate attempt identity") + context = attempt["request_context"] + _require( + bool(context) and context not in request_contexts, + "duplicate or missing request context", + ) + request = attempt["request"] + _require(type(request["id"]) in (int, str), "unsupported request ID type") + _require( + (attempt["retry_of"] is None) == (index == 0), + "example retry linkage is missing or misplaced", + ) + if attempt["retry_of"] is not None: + _require(attempt["retry_of"] in prior, "retry does not name a prior attempt") + original = prior[attempt["retry_of"]] + _require( + not _same_id(request["id"], original["request"]["id"]), "retry reused request ID" + ) + observation = attempt["observation"] + if observation["kind"] == "response_stream_lost": + _require( + "response" not in observation, "lost response observation also contains a response" + ) + outcome = "unknown" + elif observation["kind"] == "response_observed": + response = observation["response"] + _require(_same_id(response["id"], request["id"]), "response ID differs from request ID") + outcome = "producer_observed_response" + else: + raise ValueError("unsupported observation kind") + observations.append( + { + "attempt_id": identifier, + "snapshot": _snapshot_status(packet, attempt), + "execution_evidence": outcome, + } + ) + prior[identifier] = attempt + request_contexts.add(context) + return {**result, "transcript": "matched", "attempts": observations} + + +def _resign(packet: dict[str, Any]) -> None: + record = packet["record"] + record["tool_transcript"]["hash"] = GENERATOR["digest"](packet["transcript"]) + packet["record"] = sign_record( + {key: value for key, value in record.items() if key != "signature"}, + GENERATOR["fixture_key"](), + ) + + +def _snapshot(packet: dict[str, Any], index: int = 0) -> dict[str, Any]: + digest = packet["transcript"]["attempts"][index]["declarations"]["digest"] + return packet["snapshots"][digest] + + +def _recommit_snapshot(packet: dict[str, Any], index: int = 0) -> None: + snapshot = _snapshot(packet, index) + digest = GENERATOR["digest"](snapshot) + packet["snapshots"][digest] = snapshot + packet["transcript"]["attempts"][index]["declarations"]["digest"] = digest + _resign(packet) + + +def test_packet_keeps_evidence_layers_and_unknown_outcome_separate(packet, inputs): + assert _assess(packet, inputs) == { + "record": "verified", + "transcript": "matched", + "attempts": [ + { + "attempt_id": "attempt-1", + "snapshot": "complete_observed", + "execution_evidence": "unknown", + }, + { + "attempt_id": "attempt-2", + "snapshot": "complete_observed", + "execution_evidence": "producer_observed_response", + }, + ], + } + assert packet["record"]["eat_profile"].endswith(":trace-v0.2") + assert packet["record"]["policy"]["enforcement_mode"] == "declared" + assert packet["record"]["tool_transcript"]["call_count"] == 2 + first, retry = packet["transcript"]["attempts"] + assert first["retry_of"] is None and retry["retry_of"] == first["attempt_id"] + assert first["request_context"] != retry["request_context"] + assert first["declarations"]["digest"] != retry["declarations"]["digest"] + # The selected tool is unchanged; the changed declaration is uncalled. + before, after = _snapshot(packet), _snapshot(packet, 1) + assert before["pages"][0]["result"]["tools"] == after["pages"][0]["result"]["tools"] + assert before["pages"][1]["result"]["tools"] != after["pages"][1]["result"]["tools"] + + +def test_hash_preimages_independently_match_retained_full_objects(packet): + def independent(value): + return "sha256:" + hashlib.sha256(rfc8785.dumps(value)).hexdigest() + + assert independent(packet["transcript"]) == packet["record"]["tool_transcript"]["hash"] + for commitment, snapshot in packet["snapshots"].items(): + assert independent(snapshot) == commitment + assert GENERATOR["canonical_bytes"](snapshot) == rfc8785.dumps(snapshot) + + +@pytest.mark.parametrize("field", ["name", "outputSchema", "_meta"]) +def test_each_uncalled_declaration_member_is_bound(packet, inputs, field): + tool = _snapshot(packet)["pages"][1]["result"]["tools"][0] + tool[field] = { + "name": "inventory.other", + "outputSchema": {"type": "array"}, + "_meta": {"example.org/declaration-revision": "changed"}, + }[field] + result = _assess(packet, inputs) + assert result["record"] == "verified" and result["transcript"] == "matched" + assert [item["snapshot"] for item in result["attempts"]] == [ + "mismatch", + "complete_observed", + ] + + +@pytest.mark.parametrize("field", ["digest", "retry", "response", "scope"]) +def test_transcript_edits_preserve_record_signature_but_break_binding(packet, inputs, field): + retry = packet["transcript"]["attempts"][1] + if field == "digest": + retry["declarations"]["digest"] = "sha256:" + "0" * 64 + elif field == "retry": + retry["retry_of"] = None + elif field == "response": + retry["observation"]["response"]["result"]["structuredContent"]["stock"] = 4 + else: + packet["transcript"]["execution_scope"] = "different-scope" + assert _assess(packet, inputs) == {"record": "verified", "transcript": "mismatch"} + + +def test_missing_transcript_is_unavailable_not_a_verified_call(packet, inputs): + del packet["transcript"] + assert _assess(packet, inputs) == {"record": "verified", "transcript": "unavailable"} + + +def test_missing_snapshot_is_distinct_from_changed_snapshot(packet, inputs): + commitment = packet["transcript"]["attempts"][0]["declarations"]["digest"] + del packet["snapshots"][commitment] + result = _assess(packet, inputs) + assert [item["snapshot"] for item in result["attempts"]] == [ + "unavailable", + "complete_observed", + ] + assert result["attempts"][0]["execution_evidence"] == "unknown" + + +@pytest.mark.parametrize( + "change,reason", + [ + ("duplicate", "duplicate attempt identity"), + ("retry", "retry does not name a prior attempt"), + ("retry_missing", "example retry linkage is missing or misplaced"), + ("request_id", "retry reused request ID"), + ("response_id", "response ID differs from request ID"), + ("context", "duplicate or missing request context"), + ("canonicalization", "unsupported transcript canonicalization"), + ("snapshot_canonicalization", "unsupported snapshot canonicalization"), + ("outcome", "unsupported observation kind"), + ("count", "call count differs from recorded attempts"), + ], +) +def test_resigned_semantic_counterexamples_reach_the_local_gate(packet, inputs, change, reason): + first, retry = packet["transcript"]["attempts"] + if change == "duplicate": + retry["attempt_id"] = first["attempt_id"] + elif change == "retry": + retry["retry_of"] = "absent-attempt" + elif change == "retry_missing": + retry["retry_of"] = None + elif change == "request_id": + retry["request"]["id"] = first["request"]["id"] + elif change == "response_id": + retry["observation"]["response"]["id"] = str(retry["request"]["id"]) + elif change == "context": + retry["request_context"] = first["request_context"] + elif change == "canonicalization": + packet["transcript"]["canonicalization"] = "example-only/unsupported" + elif change == "snapshot_canonicalization": + first["declarations"]["canonicalization"] = "example-only/unsupported" + elif change == "outcome": + first["observation"]["kind"] = "proven_not_executed" + else: + packet["record"]["tool_transcript"]["call_count"] = 1 + _resign(packet) + _verify_record(packet, inputs) + with pytest.raises(ValueError, match=reason): + _assess(packet, inputs) + + +@pytest.mark.parametrize("change", ["missing_page", "reordered", "cursor_loop", "flag"]) +def test_resigned_incomplete_capture_never_earns_complete_snapshot(packet, inputs, change): + snapshot = _snapshot(packet) + if change == "missing_page": + snapshot["pages"].pop() + elif change == "reordered": + snapshot["pages"].reverse() + elif change == "cursor_loop": + snapshot["pages"][1]["result"]["nextCursor"] = "page-2" + else: + snapshot["collection_complete_observed"] = False + _recommit_snapshot(packet) + _verify_record(packet, inputs) + assert _assess(packet, inputs)["attempts"][0]["snapshot"] == "incomplete" + + +def test_matching_string_request_response_ids_keep_their_json_type(packet, inputs): + retry = packet["transcript"]["attempts"][1] + # The lexical value equals the first numeric ID, but its JSON type differs. + retry["request"]["id"] = "1" + retry["observation"]["response"]["id"] = "1" + _resign(packet) + assert _assess(packet, inputs)["attempts"][1]["execution_evidence"] == ( + "producer_observed_response" + ) + + +def test_wrong_trusted_key_is_not_replaced_by_the_embedded_key(packet, inputs): + inputs["trusted_key"] = key_to_jwk(Ed25519PrivateKey.from_private_bytes(b"\x07" * 32)) + with pytest.raises(ValueError, match="does not identify the trusted key"): + _assess(packet, inputs) + + +def test_signed_record_binds_the_transcript_commitment_before_resolution(packet, inputs): + packet["record"]["tool_transcript"]["hash"] = "sha256:" + "0" * 64 + del packet["transcript"] + with pytest.raises(InvalidSignature): + _assess(packet, inputs) + + +@pytest.mark.parametrize("text", ['{"a":1,"a":2}', '{"nested":[{"x":0,"x":1}]}']) +def test_duplicate_json_members_are_refused_before_commitment(text): + with pytest.raises(ValueError, match="duplicate JSON member"): + GENERATOR["load_json"](text) + + +@pytest.mark.parametrize( + "number", + ["1.0", "1e0", "0.5", "NaN", "Infinity", "-Infinity", "9007199254740992", "-9007199254740992"], +) +def test_example_numeric_subset_is_explicitly_refused(number): + with pytest.raises(ValueError, match="unsupported numeric value"): + GENERATOR["load_json"]('{"nested":[' + number + "]}") + + +def test_local_canonicalization_preserves_supported_boolean_null_and_safe_integer(): + text = '{"values":[true,false,null,9007199254740991,-9007199254740991]}' + value = GENERATOR["load_json"](text) + assert GENERATOR["canonical_bytes"](value) == text.encode() + assert value["values"][:3] == [True, False, None] From edf1e41988f2d160760053bd16a8a34d81704d5f Mon Sep 17 00:00:00 2001 From: Noah Ingwers <98993329+noah-ing@users.noreply.github.com> Date: Sat, 26 Sep 2026 07:51:48 -0500 Subject: [PATCH 2/5] test(examples): simplify MCP retry fixture checks Signed-off-by: Noah Ingwers <98993329+noah-ing@users.noreply.github.com> --- examples/mcp-retry/README.md | 103 +++---- examples/mcp-retry/gen_mcp_retry.py | 18 +- tests/test_adequacy_all_sets.py | 4 +- tests/test_mcp_retry_example.py | 432 +++++++++------------------- 4 files changed, 182 insertions(+), 375 deletions(-) diff --git a/examples/mcp-retry/README.md b/examples/mcp-retry/README.md index 8abc50b6..96f7db36 100644 --- a/examples/mcp-retry/README.md +++ b/examples/mcp-retry/README.md @@ -1,11 +1,8 @@ # Lost response, retry, changed declarations -An informative, synthetic worked example for [issue #324][issue] and the -[proposed MCP profile][draft]. It uses an ordinary signed **TRACE v0.2** record. -It does not implement or claim conformance to TRACE v0.3, and changes no SDK -acceptance rule. The small local format below is an example, not a profile proposal. - -## Read the trace +An informative, synthetic example for [issue #324][issue] and the [proposed MCP +profile][draft], using an ordinary signed **TRACE v0.2** record. Its local format +claims no TRACE v0.3 conformance and changes no SDK acceptance rule. | Step | Retained producer observation | What it supports | |---|---|---| @@ -14,51 +11,35 @@ acceptance rule. The small local format below is an example, not a profile propo | Discover again | Two new pages; an **uncalled** tool's metadata changed | A different snapshot, not a replacement for the first capture | | Attempt 2 | New request ID `2`, linked to attempt 1; a successful response recorded | Producer-reported response only; attempt 1 stays unknown | -The selected `inventory.lookup` declaration and its fictional arguments do not -change. Both attempts share an OTel `traceparent`, but have distinct producer -attempt IDs and request contexts. Correlation is not authorization or causality. -`call_count: 2` counts recorded attempts, not completed business operations. - -The [MCP 2026-07-28 changes][mcp] require a new request ID when reissuing after a -lost response stream. This packet illustrates that boundary; it does **not** run -an MCP client, server, network failure, model, policy engine or TPM. +The `inventory.lookup` declaration and fictional arguments are unchanged. A shared +OTel `traceparent` correlates distinct attempts without establishing authorization +or causality. `call_count: 2` counts attempts, not completed operations. The new ID +illustrates the [MCP retry rule][mcp]; no MCP client, server or network failure runs. ## Files and exact commitments -- `packet.json`: signed record, retained transcript and snapshots keyed by digest. -- `verification-inputs.json`: separately configured fixture trust key and fixed - evaluation time. Never derive a trusted key from an incoming packet. -- `gen_mcp_retry.py`: deterministic generator. Its public test seed is intentionally - not a secret and establishes no real-world signer identity. -- [`test_mcp_retry_example.py`][tests]: executable, test-local evidence checks and - causal negative controls. This is not a production MCP verifier. +`packet.json` holds the signed record, transcript and snapshots keyed by digest. +`verification-inputs.json` supplies a separate trust key and evaluation time; never trust +a key from the incoming packet. The deterministic `gen_mcp_retry.py` uses a public test +seed with no real-world signer identity. [Tests][tests] check the fixture and tampering. -The signature covers the record through the existing `sign_record()` / -`verify_record()` APIs. The signed `tool_transcript.hash` is SHA-256 of the -canonical **entire transcript object**. Each attempt inside that object binds its -declaration digest and canonicalization identifier. A snapshot digest is SHA-256 -of the canonical **entire snapshot object**, including ordered pages, request -cursors, cache metadata, observation flags, capture identity, and every declaration -member, not a selected-field catalog projection. +The existing `sign_record()` / `verify_record()` APIs authenticate the record. Its signed +`tool_transcript.hash` is SHA-256 of the canonical **entire transcript object**, binding +each attempt's declaration digest and canonicalization identifier. Snapshot digests are +SHA-256 of the canonical **entire snapshot object**, including ordered pages, cursors, +cache metadata, observation flags, capture identity and every declaration member. +Committed `format` members distinguish transcript and snapshot hash domains. -The local canonicalization identifier is `example-only/jcs-safe-integers-v1`: -UTF-8 RFC 8785 using `rfc8785`, duplicate JSON members rejected before conversion, -all floating-point numbers refused, integers restricted to the inclusive range -`[-9007199254740991, 9007199254740991]`. Booleans and null are retained. Array order -is preserved. This deliberately does not support all numbers allowed in MCP -schemas; no rounding, coercion or dropping unsupported members occurs. +`example-only/jcs-safe-integers-v1` means UTF-8 RFC 8785 via `rfc8785`: duplicate members +are rejected before conversion, floats refused and integers limited to the inclusive +range `[-9007199254740991, 9007199254740991]`. Booleans, null and array order are retained. +Unsupported MCP schema numbers are refused without rounding, coercion or dropping members. +The identifiers are unregistered local constants; fixture whitespace is not committed. -The committed `format` members distinguish transcript and snapshot hash domains. -The `example-only/...` names are local constants, **not registered profile IDs**. -Pretty-print whitespace in the fixture is not committed; the canonical JSON -values are. These are retained synthetic JSON result objects, not captured HTTP -bytes. Headers, connection identity, server authentication and transport timing -are not evidenced. Every result page is retained in full for this synthetic -capture; page completion does not establish a single atomic server revision. +Every synthetic JSON result page is retained in full. The capture excludes HTTP bytes, +headers, connection identity, server authentication and timing; traversal does not prove atomicity. -## Reproduce - -From the repository root, with its development environment installed: +## Reproduce (repository root, development dependencies installed) ```bash python examples/mcp-retry/gen_mcp_retry.py @@ -66,31 +47,19 @@ pytest tests/test_mcp_retry_example.py tests/test_generators_reproduce_fixtures. git diff --exit-code -- examples/mcp-retry/packet.json examples/mcp-retry/verification-inputs.json ``` -The existing generator test deletes the example JSON from a temporary checkout -and regenerates it before comparing bytes. Negative controls use copies of the -committed packet, not unchecked generator verdicts. The tests separately check -record authentication, transcript integrity and snapshot availability/integrity. -Missing retained evidence is unavailable, not a successful comparison; changed -retained evidence is a mismatch. Neither changes the narrower fact that an -otherwise intact record signature verifies. - -## Assurance ceiling and remaining work +The generator test regenerates JSON in a temporary checkout and compares bytes. Tests +distinguish missing (unavailable) evidence from present evidence with a mismatched digest; +an otherwise intact record signature can still verify in either case. -The packet authenticates a fixture producer's commitments. It does not prove -truthful collection, what an agent actually saw, a complete execution history, -server identity, actual execution, authorization, safe behavior, runtime integrity, -hardware provenance, or exactly-once execution. `policy.enforcement_mode` is -explicitly `declared`; `appraisal.status` is `none`. Runtime, model and build fields -are synthetic placeholders, not attestations or SLSA evidence. No revocation -check is performed. The two evidence sources remain separate: a producer-signed -response observation is not a server-signed response. +## Limits -This covers the requested small lost-response/retry/changed-declarations example, -not every acceptance case in draft section 6. Cached reuse/freshness policy, -notifications during collection, resource limits, multi-round-trip continuations, -independent server proofs and confidentiality/retention remain future work. -Wire schema, canonicalization choices, resource bounds and interoperable verifier -rules still require the draft's normal adoption process. +Producer commitments do not prove truthful collection, agent visibility, execution history, +server identity, execution, authorization, safety, runtime integrity, hardware provenance +or exactly-once behavior. Producer observations are not server-signed responses. Enforcement +is `declared`, appraisal is `none`, and runtime/model/build fields are synthetic, not +attestations or SLSA evidence. No revocation check is performed. Freshness, collection +notifications, resource limits, continuations and confidentiality/retention are outside +scope. Interoperable formats and verifier rules still require the draft's adoption process. [issue]: https://github.com/agentrust-io/trace-spec/issues/324 [draft]: ../../spec/mcp-profile-v0.3-draft.md diff --git a/examples/mcp-retry/gen_mcp_retry.py b/examples/mcp-retry/gen_mcp_retry.py index fbf01797..57c18046 100644 --- a/examples/mcp-retry/gen_mcp_retry.py +++ b/examples/mcp-retry/gen_mcp_retry.py @@ -26,14 +26,16 @@ def canonical_bytes(value: Any) -> bytes: """Example-local subset: JCS, with all floats and unsafe integers refused.""" - if isinstance(value, float) or (type(value) is int and abs(value) > 9007199254740991): - raise ValueError("unsupported numeric value in example canonicalization") - if isinstance(value, dict): - for item in value.values(): - canonical_bytes(item) - elif isinstance(value, list): - for item in value: - canonical_bytes(item) + pending = [value] + while pending: + item = pending.pop() + if isinstance(item, float) or (type(item) is int and abs(item) > 9007199254740991): + raise ValueError("unsupported numeric value in example canonicalization") + if isinstance(item, dict): + pending.extend(item.values()) + elif isinstance(item, list): + pending.extend(item) + # Validate once, serialize once; do not re-encode every nested subtree. return rfc8785.dumps(value) diff --git a/tests/test_adequacy_all_sets.py b/tests/test_adequacy_all_sets.py index 8b798b06..bb20fc04 100644 --- a/tests/test_adequacy_all_sets.py +++ b/tests/test_adequacy_all_sets.py @@ -238,8 +238,8 @@ def test_the_loader_reads_a_different_set_for_each_name() -> None: MEASURED_ELSEWHERE = { # An informative packet, not a normative accept/reject conformance corpus. "mcp-retry": "tests/test_mcp_retry_example.py, which separately checks the " - "record signature, full transcript/snapshot commitments, observed " - "pagination and retry outcomes with isolated causal changes", + "fixture observations, record signature, full transcript/snapshot " + "commitments, tampering and missing evidence", # Not loadable here: every vector verifies, and the outcomes are per-surface # resolvability rather than accept/reject, so `trivially_satisfied_by` would # grade the set as passable by an implementation that accepts everything. diff --git a/tests/test_mcp_retry_example.py b/tests/test_mcp_retry_example.py index 10f822f8..40a5912b 100644 --- a/tests/test_mcp_retry_example.py +++ b/tests/test_mcp_retry_example.py @@ -1,49 +1,33 @@ -"""Assess the retained MCP retry example, not a normative MCP verifier. - -The SDK checks the v0.2 record. The small assessment below checks this example's -local commitments and observations separately. Its outcomes are not conformance -claims, server authentication, execution proof, or complete-history claims. -""" +"""Check the worked example's retained facts and commitments, not an MCP verifier.""" from __future__ import annotations import hashlib import runpy from pathlib import Path -from typing import Any import pytest import rfc8785 from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey -from agentrust_trace import key_to_jwk, sign_record, verify_record +from agentrust_trace import key_to_jwk, verify_record DIRECTORY = Path(__file__).resolve().parents[1] / "examples" / "mcp-retry" GENERATOR = runpy.run_path(str(DIRECTORY / "gen_mcp_retry.py")) -CANONICALIZATION = GENERATOR["CANONICALIZATION"] @pytest.fixture -def packet() -> dict[str, Any]: +def packet(): return GENERATOR["load_json"]((DIRECTORY / "packet.json").read_text("utf-8")) @pytest.fixture -def inputs() -> dict[str, Any]: +def inputs(): return GENERATOR["load_json"]((DIRECTORY / "verification-inputs.json").read_text("utf-8")) -def _require(condition: bool, reason: str) -> None: - if not condition: - raise ValueError(reason) - - -def _same_id(left: Any, right: Any) -> bool: - return type(left) is type(right) and left == right - - -def _verify_record(packet: dict[str, Any], inputs: dict[str, Any]) -> None: +def _verify_record(packet, inputs): result = verify_record( packet["record"], inputs["trusted_key"], @@ -55,312 +39,156 @@ def _verify_record(packet: dict[str, Any], inputs: dict[str, Any]) -> None: assert result.revocation.outcome == "no_check_performed" -def _snapshot_status(packet: dict[str, Any], attempt: dict[str, Any]) -> str: - reference = attempt["declarations"] - _require( - reference["canonicalization"] == CANONICALIZATION, "unsupported snapshot canonicalization" - ) - retained = packet.get("snapshots", {}) - commitment = reference["digest"] - if commitment not in retained: +def _commitment(value, expected): + # Independent of the generator's digest helper. This is not a semantic appraisal. + if value is None: return "unavailable" - snapshot = retained[commitment] - if GENERATOR["digest"](snapshot) != commitment: - return "mismatch" - _require( - snapshot["canonicalization"] == CANONICALIZATION, "unsupported snapshot canonicalization" - ) - _require(snapshot["format"] == GENERATOR["SNAPSHOT_FORMAT"], "unsupported snapshot format") - _require( - snapshot["server_observed"] == attempt["server_observed"] - and snapshot["protocol_revision"] == attempt["protocol_revision"], - "snapshot context differs from attempt", - ) + actual = "sha256:" + hashlib.sha256(rfc8785.dumps(value)).hexdigest() + return "matched" if actual == expected else "mismatch" + - # This establishes an observed terminal traversal, not an atomic catalog. - pages = snapshot["pages"] - if not pages or snapshot["collection_complete_observed"] is not True: - return "incomplete" - expected_cursor: str | None = None - seen: set[str | None] = set() - for index, page in enumerate(pages): - cursor = page["request_cursor"] - if not _same_id(cursor, expected_cursor) or cursor in seen: - return "incomplete" - seen.add(cursor) - result = page["result"] - if result["resultType"] != "complete": - return "incomplete" - if "nextCursor" not in result: - return "complete_observed" if index == len(pages) - 1 else "incomplete" - expected_cursor = result["nextCursor"] - if type(expected_cursor) is not str or expected_cursor in seen: - return "incomplete" - return "incomplete" - - -def _assess(packet: dict[str, Any], inputs: dict[str, Any]) -> dict[str, Any]: - """Check only the local example contract; no untrusted network resolution.""" +def test_record_and_full_retained_commitments_verify_separately(packet, inputs): _verify_record(packet, inputs) - result: dict[str, Any] = {"record": "verified"} - transcript = packet.get("transcript") - if transcript is None: - return {**result, "transcript": "unavailable"} - if GENERATOR["digest"](transcript) != packet["record"]["tool_transcript"]["hash"]: - return {**result, "transcript": "mismatch"} - _require( - transcript["canonicalization"] == CANONICALIZATION, - "unsupported transcript canonicalization", - ) - _require( - transcript["format"] == GENERATOR["TRANSCRIPT_FORMAT"], "unsupported transcript format" - ) - _require( - isinstance(transcript["execution_scope"], str) and bool(transcript["execution_scope"]), - "missing execution scope", - ) - attempts = transcript["attempts"] - _require( - packet["record"]["tool_transcript"]["call_count"] == len(attempts), - "call count differs from recorded attempts", - ) - _require(len(attempts) == 2, "example requires two retained attempts") - prior: dict[str, dict[str, Any]] = {} - request_contexts: set[str] = set() - observations = [] - for index, attempt in enumerate(attempts): - identifier = attempt["attempt_id"] - _require(identifier not in prior, "duplicate attempt identity") - context = attempt["request_context"] - _require( - bool(context) and context not in request_contexts, - "duplicate or missing request context", - ) - request = attempt["request"] - _require(type(request["id"]) in (int, str), "unsupported request ID type") - _require( - (attempt["retry_of"] is None) == (index == 0), - "example retry linkage is missing or misplaced", + record, transcript = packet["record"], packet["transcript"] + assert record["eat_profile"] == "tag:agentrust-io.com,2026:trace-v0.2" + assert record["policy"]["enforcement_mode"] == "declared" + assert record["appraisal"]["status"] == "none" + assert _commitment(transcript, record["tool_transcript"]["hash"]) == "matched" + assert transcript["format"] == "example-only/mcp-retry-transcript-v1" + for attempt in transcript["attempts"]: + reference = attempt["declarations"] + snapshot = packet["snapshots"][reference["digest"]] + assert _commitment(snapshot, reference["digest"]) == "matched" + assert snapshot["format"] == "example-only/mcp-declarations-v1" + assert ( + snapshot["canonicalization"] + == reference["canonicalization"] + == transcript["canonicalization"] + == "example-only/jcs-safe-integers-v1" ) - if attempt["retry_of"] is not None: - _require(attempt["retry_of"] in prior, "retry does not name a prior attempt") - original = prior[attempt["retry_of"]] - _require( - not _same_id(request["id"], original["request"]["id"]), "retry reused request ID" - ) - observation = attempt["observation"] - if observation["kind"] == "response_stream_lost": - _require( - "response" not in observation, "lost response observation also contains a response" - ) - outcome = "unknown" - elif observation["kind"] == "response_observed": - response = observation["response"] - _require(_same_id(response["id"], request["id"]), "response ID differs from request ID") - outcome = "producer_observed_response" - else: - raise ValueError("unsupported observation kind") - observations.append( - { - "attempt_id": identifier, - "snapshot": _snapshot_status(packet, attempt), - "execution_evidence": outcome, - } - ) - prior[identifier] = attempt - request_contexts.add(context) - return {**result, "transcript": "matched", "attempts": observations} - + assert snapshot["server_observed"] == attempt["server_observed"] + assert snapshot["protocol_revision"] == attempt["protocol_revision"] -def _resign(packet: dict[str, Any]) -> None: - record = packet["record"] - record["tool_transcript"]["hash"] = GENERATOR["digest"](packet["transcript"]) - packet["record"] = sign_record( - {key: value for key, value in record.items() if key != "signature"}, - GENERATOR["fixture_key"](), - ) - -def _snapshot(packet: dict[str, Any], index: int = 0) -> dict[str, Any]: - digest = packet["transcript"]["attempts"][index]["declarations"]["digest"] - return packet["snapshots"][digest] - - -def _recommit_snapshot(packet: dict[str, Any], index: int = 0) -> None: - snapshot = _snapshot(packet, index) - digest = GENERATOR["digest"](snapshot) - packet["snapshots"][digest] = snapshot - packet["transcript"]["attempts"][index]["declarations"]["digest"] = digest - _resign(packet) - - -def test_packet_keeps_evidence_layers_and_unknown_outcome_separate(packet, inputs): - assert _assess(packet, inputs) == { - "record": "verified", - "transcript": "matched", - "attempts": [ - { - "attempt_id": "attempt-1", - "snapshot": "complete_observed", - "execution_evidence": "unknown", - }, - { - "attempt_id": "attempt-2", - "snapshot": "complete_observed", - "execution_evidence": "producer_observed_response", - }, - ], - } - assert packet["record"]["eat_profile"].endswith(":trace-v0.2") - assert packet["record"]["policy"]["enforcement_mode"] == "declared" - assert packet["record"]["tool_transcript"]["call_count"] == 2 +def test_retry_retains_the_lost_response_without_inventing_an_execution_outcome(packet): first, retry = packet["transcript"]["attempts"] + assert packet["record"]["tool_transcript"]["call_count"] == 2 + assert first["attempt_id"] != retry["attempt_id"] assert first["retry_of"] is None and retry["retry_of"] == first["attempt_id"] assert first["request_context"] != retry["request_context"] - assert first["declarations"]["digest"] != retry["declarations"]["digest"] - # The selected tool is unchanged; the changed declaration is uncalled. - before, after = _snapshot(packet), _snapshot(packet, 1) - assert before["pages"][0]["result"]["tools"] == after["pages"][0]["result"]["tools"] - assert before["pages"][1]["result"]["tools"] != after["pages"][1]["result"]["tools"] - - -def test_hash_preimages_independently_match_retained_full_objects(packet): - def independent(value): - return "sha256:" + hashlib.sha256(rfc8785.dumps(value)).hexdigest() - - assert independent(packet["transcript"]) == packet["record"]["tool_transcript"]["hash"] - for commitment, snapshot in packet["snapshots"].items(): - assert independent(snapshot) == commitment - assert GENERATOR["canonical_bytes"](snapshot) == rfc8785.dumps(snapshot) - - -@pytest.mark.parametrize("field", ["name", "outputSchema", "_meta"]) -def test_each_uncalled_declaration_member_is_bound(packet, inputs, field): - tool = _snapshot(packet)["pages"][1]["result"]["tools"][0] - tool[field] = { - "name": "inventory.other", - "outputSchema": {"type": "array"}, - "_meta": {"example.org/declaration-revision": "changed"}, - }[field] - result = _assess(packet, inputs) - assert result["record"] == "verified" and result["transcript"] == "matched" - assert [item["snapshot"] for item in result["attempts"]] == [ - "mismatch", - "complete_observed", + assert type(first["request"]["id"]) is type(retry["request"]["id"]) is int + assert first["request"]["id"] != retry["request"]["id"] + assert first["request"]["params"] == retry["request"]["params"] + # No response or execution conclusion is added to the first observation. + assert first["observation"] == {"kind": "response_stream_lost"} + assert retry["observation"]["kind"] == "response_observed" + response = retry["observation"]["response"] + assert type(response["id"]) is int and response["id"] == retry["request"]["id"] + assert response["result"]["resultType"] == "complete" + assert response["result"]["isError"] is False + assert response["result"]["structuredContent"] == {"stock": 3} + + +def test_both_full_paginated_captures_survive_with_the_uncalled_tool_change(packet): + before, after = [ + packet["snapshots"][attempt["declarations"]["digest"]] + for attempt in packet["transcript"]["attempts"] ] + assert before["capture_id"] != after["capture_id"] + for snapshot in (before, after): + first, last = snapshot["pages"] + assert snapshot["source"] == "fresh-discovery" + assert snapshot["collection_complete_observed"] is True + assert first["request_cursor"] is None + assert first["result"]["nextCursor"] == last["request_cursor"] == "page-2" + assert "nextCursor" not in last["result"] + for page in (first, last): + assert page["result"]["resultType"] == "complete" + assert page["result"]["ttlMs"] == 0 + assert page["result"]["cacheScope"] == "private" + assert before["pages"][0] == after["pages"][0] # Selected tool unchanged. + uncalled = [s["pages"][1]["result"]["tools"][0] for s in (before, after)] + assert [t["_meta"]["example.org/declaration-revision"] for t in uncalled] == ["one", "two"] -@pytest.mark.parametrize("field", ["digest", "retry", "response", "scope"]) -def test_transcript_edits_preserve_record_signature_but_break_binding(packet, inputs, field): - retry = packet["transcript"]["attempts"][1] - if field == "digest": - retry["declarations"]["digest"] = "sha256:" + "0" * 64 - elif field == "retry": - retry["retry_of"] = None - elif field == "response": - retry["observation"]["response"]["result"]["structuredContent"]["stock"] = 4 - else: - packet["transcript"]["execution_scope"] = "different-scope" - assert _assess(packet, inputs) == {"record": "verified", "transcript": "mismatch"} - - -def test_missing_transcript_is_unavailable_not_a_verified_call(packet, inputs): - del packet["transcript"] - assert _assess(packet, inputs) == {"record": "verified", "transcript": "unavailable"} - - -def test_missing_snapshot_is_distinct_from_changed_snapshot(packet, inputs): - commitment = packet["transcript"]["attempts"][0]["declarations"]["digest"] - del packet["snapshots"][commitment] - result = _assess(packet, inputs) - assert [item["snapshot"] for item in result["attempts"]] == [ - "unavailable", - "complete_observed", - ] - assert result["attempts"][0]["execution_evidence"] == "unknown" +@pytest.mark.parametrize( + "field,value", + [ + ("name", "inventory.other"), + ("outputSchema", {"type": "array"}), + ("_meta", {"example.org/declaration-revision": "changed"}), + ], +) +def test_each_uncalled_declaration_member_is_committed(packet, inputs, field, value): + reference = packet["transcript"]["attempts"][0]["declarations"]["digest"] + snapshot = packet["snapshots"][reference] + snapshot["pages"][1]["result"]["tools"][0][field] = value + _verify_record(packet, inputs) + assert ( + _commitment(packet["transcript"], packet["record"]["tool_transcript"]["hash"]) == "matched" + ) + assert _commitment(snapshot, reference) == "mismatch" @pytest.mark.parametrize( - "change,reason", + "path,value", [ - ("duplicate", "duplicate attempt identity"), - ("retry", "retry does not name a prior attempt"), - ("retry_missing", "example retry linkage is missing or misplaced"), - ("request_id", "retry reused request ID"), - ("response_id", "response ID differs from request ID"), - ("context", "duplicate or missing request context"), - ("canonicalization", "unsupported transcript canonicalization"), - ("snapshot_canonicalization", "unsupported snapshot canonicalization"), - ("outcome", "unsupported observation kind"), - ("count", "call count differs from recorded attempts"), + (("attempts", 1, "declarations", "digest"), "sha256:" + "0" * 64), + (("attempts", 1, "retry_of"), None), + (("attempts", 1, "observation", "response", "result", "structuredContent", "stock"), 4), + (("attempts", 0, "observation", "kind"), "proven_not_executed"), + (("attempts", 1, "request", "id"), "2"), + (("execution_scope",), "different-scope"), ], ) -def test_resigned_semantic_counterexamples_reach_the_local_gate(packet, inputs, change, reason): - first, retry = packet["transcript"]["attempts"] - if change == "duplicate": - retry["attempt_id"] = first["attempt_id"] - elif change == "retry": - retry["retry_of"] = "absent-attempt" - elif change == "retry_missing": - retry["retry_of"] = None - elif change == "request_id": - retry["request"]["id"] = first["request"]["id"] - elif change == "response_id": - retry["observation"]["response"]["id"] = str(retry["request"]["id"]) - elif change == "context": - retry["request_context"] = first["request_context"] - elif change == "canonicalization": - packet["transcript"]["canonicalization"] = "example-only/unsupported" - elif change == "snapshot_canonicalization": - first["declarations"]["canonicalization"] = "example-only/unsupported" - elif change == "outcome": - first["observation"]["kind"] = "proven_not_executed" - else: - packet["record"]["tool_transcript"]["call_count"] = 1 - _resign(packet) +def test_attempt_edits_preserve_the_record_signature_but_break_its_commitment( + packet, + inputs, + path, + value, +): + parent = packet["transcript"] + for member in path[:-1]: + parent = parent[member] + parent[path[-1]] = value _verify_record(packet, inputs) - with pytest.raises(ValueError, match=reason): - _assess(packet, inputs) - - -@pytest.mark.parametrize("change", ["missing_page", "reordered", "cursor_loop", "flag"]) -def test_resigned_incomplete_capture_never_earns_complete_snapshot(packet, inputs, change): - snapshot = _snapshot(packet) - if change == "missing_page": - snapshot["pages"].pop() - elif change == "reordered": - snapshot["pages"].reverse() - elif change == "cursor_loop": - snapshot["pages"][1]["result"]["nextCursor"] = "page-2" - else: - snapshot["collection_complete_observed"] = False - _recommit_snapshot(packet) + assert ( + _commitment(packet["transcript"], packet["record"]["tool_transcript"]["hash"]) == "mismatch" + ) + + +def test_dropping_a_retained_page_breaks_the_snapshot_commitment(packet, inputs): + reference = packet["transcript"]["attempts"][0]["declarations"]["digest"] + snapshot = packet["snapshots"][reference] + snapshot["pages"].pop() _verify_record(packet, inputs) - assert _assess(packet, inputs)["attempts"][0]["snapshot"] == "incomplete" + assert _commitment(snapshot, reference) == "mismatch" -def test_matching_string_request_response_ids_keep_their_json_type(packet, inputs): - retry = packet["transcript"]["attempts"][1] - # The lexical value equals the first numeric ID, but its JSON type differs. - retry["request"]["id"] = "1" - retry["observation"]["response"]["id"] = "1" - _resign(packet) - assert _assess(packet, inputs)["attempts"][1]["execution_evidence"] == ( - "producer_observed_response" - ) +@pytest.mark.parametrize("missing", ["transcript", "snapshot"]) +def test_missing_evidence_is_unavailable_not_a_successful_comparison(packet, inputs, missing): + if missing == "transcript": + del packet["transcript"] + retained = packet.get("transcript") + expected = packet["record"]["tool_transcript"]["hash"] + else: + expected = packet["transcript"]["attempts"][0]["declarations"]["digest"] + del packet["snapshots"][expected] + retained = packet["snapshots"].get(expected) + _verify_record(packet, inputs) + assert _commitment(retained, expected) == "unavailable" def test_wrong_trusted_key_is_not_replaced_by_the_embedded_key(packet, inputs): inputs["trusted_key"] = key_to_jwk(Ed25519PrivateKey.from_private_bytes(b"\x07" * 32)) with pytest.raises(ValueError, match="does not identify the trusted key"): - _assess(packet, inputs) + _verify_record(packet, inputs) -def test_signed_record_binds_the_transcript_commitment_before_resolution(packet, inputs): +def test_changing_the_signed_transcript_hash_invalidates_the_signature(packet, inputs): packet["record"]["tool_transcript"]["hash"] = "sha256:" + "0" * 64 - del packet["transcript"] with pytest.raises(InvalidSignature): - _assess(packet, inputs) + _verify_record(packet, inputs) @pytest.mark.parametrize("text", ['{"a":1,"a":2}', '{"nested":[{"x":0,"x":1}]}']) @@ -371,7 +199,16 @@ def test_duplicate_json_members_are_refused_before_commitment(text): @pytest.mark.parametrize( "number", - ["1.0", "1e0", "0.5", "NaN", "Infinity", "-Infinity", "9007199254740992", "-9007199254740992"], + [ + "1.0", + "1e0", + "0.5", + "NaN", + "Infinity", + "-Infinity", + "9007199254740992", + "-9007199254740992", + ], ) def test_example_numeric_subset_is_explicitly_refused(number): with pytest.raises(ValueError, match="unsupported numeric value"): @@ -382,4 +219,3 @@ def test_local_canonicalization_preserves_supported_boolean_null_and_safe_intege text = '{"values":[true,false,null,9007199254740991,-9007199254740991]}' value = GENERATOR["load_json"](text) assert GENERATOR["canonical_bytes"](value) == text.encode() - assert value["values"][:3] == [True, False, None] From 58e8659eadf2762462cf006e00534f412bf3cabd Mon Sep 17 00:00:00 2001 From: Noah Ingwers <98993329+noah-ing@users.noreply.github.com> Date: Sat, 26 Sep 2026 08:59:07 -0500 Subject: [PATCH 3/5] examples: generate MCP retry evidence on demand Signed-off-by: Noah Ingwers <98993329+noah-ing@users.noreply.github.com> --- CHANGELOG.md | 4 +- examples/README.md | 4 +- examples/mcp-retry/README.md | 111 +++---- .../{gen_mcp_retry.py => mcp_retry.py} | 16 +- examples/mcp-retry/packet.json | 290 ------------------ examples/mcp-retry/verification-inputs.json | 8 - spec/mcp-profile-v0.3-draft.md | 2 +- tests/test_adequacy_all_sets.py | 4 - tests/test_mcp_retry_example.py | 53 +++- 9 files changed, 110 insertions(+), 382 deletions(-) rename examples/mcp-retry/{gen_mcp_retry.py => mcp_retry.py} (92%) delete mode 100644 examples/mcp-retry/packet.json delete mode 100644 examples/mcp-retry/verification-inputs.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 34102797..376455e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,8 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. ## [Unreleased] -- Add an informative MCP lost-response/retry example with full paginated declaration - snapshots, signed transcript commitments and causal evidence checks for #324. +- Add an executable MCP lost-response/retry example with full paginated declaration + snapshots, signed transcript commitments and focused integrity checks for #324. Keep the first execution outcome unknown after a reported successful retry. Uses an example-local format with a v0.2 record, not a v0.3 implementation. diff --git a/examples/README.md b/examples/README.md index e0902c13..01133b7a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -49,8 +49,8 @@ checking any archived record, including these. verifies exactly as the pass does, unresolvable, and an issuer whose key the relying party does not hold. Every digest recomputes; see that directory's README and `docs/references-registry.md`. -- [`mcp-retry/`](mcp-retry/README.md): one signed v0.2 record binding two synthetic - MCP attempts and full paginated declaration snapshots. A lost response stays +- [`mcp-retry/`](mcp-retry/README.md): executable example writing a signed v0.2 record + binding two synthetic MCP attempts and full paginated declaration snapshots. A lost response stays unknown after retry success; an uncalled tool changes between captures. Informative local format, not an implementation of the proposed v0.3 profile. diff --git a/examples/mcp-retry/README.md b/examples/mcp-retry/README.md index 96f7db36..ef350e1b 100644 --- a/examples/mcp-retry/README.md +++ b/examples/mcp-retry/README.md @@ -1,67 +1,52 @@ # Lost response, retry, changed declarations -An informative, synthetic example for [issue #324][issue] and the [proposed MCP -profile][draft], using an ordinary signed **TRACE v0.2** record. Its local format -claims no TRACE v0.3 conformance and changes no SDK acceptance rule. - -| Step | Retained producer observation | What it supports | -|---|---|---| -| Discover | Two ordered `tools/list` result pages, through the terminal page | A reported complete traversal, not an atomic server catalog | -| Attempt 1 | Request ID `1`; response stream lost | Execution outcome unknown, not proof of non-execution | -| Discover again | Two new pages; an **uncalled** tool's metadata changed | A different snapshot, not a replacement for the first capture | -| Attempt 2 | New request ID `2`, linked to attempt 1; a successful response recorded | Producer-reported response only; attempt 1 stays unknown | - -The `inventory.lookup` declaration and fictional arguments are unchanged. A shared -OTel `traceparent` correlates distinct attempts without establishing authorization -or causality. `call_count: 2` counts attempts, not completed operations. The new ID -illustrates the [MCP retry rule][mcp]; no MCP client, server or network failure runs. - -## Files and exact commitments - -`packet.json` holds the signed record, transcript and snapshots keyed by digest. -`verification-inputs.json` supplies a separate trust key and evaluation time; never trust -a key from the incoming packet. The deterministic `gen_mcp_retry.py` uses a public test -seed with no real-world signer identity. [Tests][tests] check the fixture and tampering. - -The existing `sign_record()` / `verify_record()` APIs authenticate the record. Its signed -`tool_transcript.hash` is SHA-256 of the canonical **entire transcript object**, binding -each attempt's declaration digest and canonicalization identifier. Snapshot digests are -SHA-256 of the canonical **entire snapshot object**, including ordered pages, cursors, -cache metadata, observation flags, capture identity and every declaration member. -Committed `format` members distinguish transcript and snapshot hash domains. - -`example-only/jcs-safe-integers-v1` means UTF-8 RFC 8785 via `rfc8785`: duplicate members -are rejected before conversion, floats refused and integers limited to the inclusive -range `[-9007199254740991, 9007199254740991]`. Booleans, null and array order are retained. -Unsupported MCP schema numbers are refused without rounding, coercion or dropping members. -The identifiers are unregistered local constants; fixture whitespace is not committed. - -Every synthetic JSON result page is retained in full. The capture excludes HTTP bytes, -headers, connection identity, server authentication and timing; traversal does not prove atomicity. - -## Reproduce (repository root, development dependencies installed) - -```bash -python examples/mcp-retry/gen_mcp_retry.py -pytest tests/test_mcp_retry_example.py tests/test_generators_reproduce_fixtures.py -git diff --exit-code -- examples/mcp-retry/packet.json examples/mcp-retry/verification-inputs.json +An executable, synthetic example for [#324](https://github.com/agentrust-io/trace-spec/issues/324) +and the [proposed MCP profile](../../spec/mcp-profile-v0.3-draft.md), not a v0.3 implementation. +Install the repository's development dependencies, then run from its root: + +```sh +example_out=$(mktemp -d) +python examples/mcp-retry/mcp_retry.py --out "$example_out" +python -m json.tool "$example_out/packet.json" +pytest tests/test_mcp_retry_example.py ``` -The generator test regenerates JSON in a temporary checkout and compares bytes. Tests -distinguish missing (unavailable) evidence from present evidence with a mismatched digest; -an otherwise intact record signature can still verify in either case. - -## Limits - -Producer commitments do not prove truthful collection, agent visibility, execution history, -server identity, execution, authorization, safety, runtime integrity, hardware provenance -or exactly-once behavior. Producer observations are not server-signed responses. Enforcement -is `declared`, appraisal is `none`, and runtime/model/build fields are synthetic, not -attestations or SLSA evidence. No revocation check is performed. Freshness, collection -notifications, resource limits, continuations and confidentiality/retention are outside -scope. Interoperable formats and verifier rules still require the draft's adoption process. - -[issue]: https://github.com/agentrust-io/trace-spec/issues/324 -[draft]: ../../spec/mcp-profile-v0.3-draft.md -[mcp]: https://modelcontextprotocol.io/specification/2026-07-28/changelog -[tests]: ../../tests/test_mcp_retry_example.py +The script writes `packet.json` and separate `verification-inputs.json` (trusted key +and fixed evaluation time). These generated files are not committed; the script is +the single source. Tests reproduce them twice and pin their exact bytes, then check +the signature with the separate trusted key and independently recompute commitments. +The deterministic public signing seed is test material with no real-world identity. + +| Observation | Meaning | +|---|---| +| Two ordered `tools/list` pages through termination | Reported complete traversal, not an atomic catalog | +| Request `1` loses its response stream | Execution outcome remains unknown | +| Fresh discovery; an uncalled tool's metadata changes | Both full snapshots retained; selected tool unchanged | +| Retry `2` links to `1` and records success | New attempt, not proof the first did not execute | + +The requests retain the same arguments and OTel `traceparent`; correlation grants +no authority or causality. `call_count: 2` counts attempts, not completed operations. +This illustrates the [MCP retry rule](https://modelcontextprotocol.io/specification/2026-07-28/changelog); +no client, server or network failure runs. + +## Exact commitments and limits + +The v0.2 record's signed `tool_transcript.hash` is SHA-256 of the canonical **entire +transcript**, including each attempt's snapshot digest. Snapshot digests are SHA-256 +of the canonical **entire snapshot**: all declarations (called or not), ordered result +pages, cursors, cache metadata and capture context. Committed `format` members +distinguish the two hash domains. + +`example-only/jcs-safe-integers-v1` is UTF-8 RFC 8785 via `rfc8785`: reject duplicate +members, all floats and integers outside `[-9007199254740991, 9007199254740991]`; +preserve booleans, null and array order. No coercion or dropped members. Identifiers +are local, not registered formats; JSON whitespace is not committed. Missing evidence +is unavailable, not a match; changed retained evidence mismatches even if the record +signature still verifies. Tests cover both boundaries, wrong keys and signed-field edits. + +All observations are producer assertions, not authenticated server responses. +Enforcement is `declared`, appraisal `none`, and runtime/model/build fields synthetic. +No revocation check, HTTP capture, freshness-policy or resource-limit appraisal. +It does not establish truthful collection, agent visibility, complete history, execution, +authorization, safety, runtime integrity, hardware provenance or exactly-once behavior. +SDK acceptance is unchanged; interoperable formats still require the draft's adoption. diff --git a/examples/mcp-retry/gen_mcp_retry.py b/examples/mcp-retry/mcp_retry.py similarity index 92% rename from examples/mcp-retry/gen_mcp_retry.py rename to examples/mcp-retry/mcp_retry.py index 57c18046..dfc1302a 100644 --- a/examples/mcp-retry/gen_mcp_retry.py +++ b/examples/mcp-retry/mcp_retry.py @@ -1,11 +1,12 @@ -"""Generate an informative, synthetic MCP retry packet, not a v0.3 profile. +"""Write an informative, synthetic MCP retry packet, not a v0.3 profile. -The public seed is fixture material, never a production signing identity. -Run from the repository root after installing its development dependencies. +Run with --out DIR after installing the repository's development dependencies. +The public seed is test material, never a production signing identity. """ from __future__ import annotations +import argparse import hashlib import json from pathlib import Path @@ -133,7 +134,7 @@ def packet() -> dict[str, Any]: after["pages"][1]["result"]["tools"][0]["_meta"]["example.org/declaration-revision"] = "two" snapshots = {digest(s): s for s in (before, after)} attempts = [] - for index, declaration in enumerate((before, after), start=1): + for index, declaration_digest in enumerate(snapshots, start=1): request = { "jsonrpc": "2.0", "id": index, @@ -175,7 +176,7 @@ def packet() -> dict[str, Any]: "protocol_revision": PROTOCOL, "request": request, "declarations": { - "digest": digest(declaration), + "digest": declaration_digest, "canonicalization": CANONICALIZATION, }, "observation": observation, @@ -209,7 +210,10 @@ def packet() -> dict[str, Any]: def main() -> None: - directory = Path(__file__).parent + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", required=True, type=Path, help="directory for generated JSON") + directory = parser.parse_args().out + directory.mkdir(parents=True, exist_ok=True) artifacts = { "packet.json": packet(), "verification-inputs.json": {"trusted_key": key_to_jwk(fixture_key()), "now": NOW}, diff --git a/examples/mcp-retry/packet.json b/examples/mcp-retry/packet.json deleted file mode 100644 index b3b44c66..00000000 --- a/examples/mcp-retry/packet.json +++ /dev/null @@ -1,290 +0,0 @@ -{ - "record": { - "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", - "iat": 1790294400, - "subject": "spiffe://example.org/agent/synthetic-mcp-producer", - "model": { - "provider": "synthetic", - "model_id": "no-model-executed" - }, - "runtime": { - "platform": "software-only", - "measurement": "sha256:ef49ce9e877bdf9b6bcc1cb0b56437b564f2f91d6baf9a54024f04eda3447b35" - }, - "policy": { - "bundle_hash": "sha256:e4a9b5845f1d08eaf5c277fc324a7268c80b87570c0e7dfa7b588d43af6f1f2b", - "enforcement_mode": "declared" - }, - "data_class": "public", - "build_provenance": { - "slsa_level": 0, - "digest": "sha256:b4b57759a9248accddb8c61cde857f5d43286984a4456e38145619e69069e6a1" - }, - "appraisal": { - "status": "none", - "verifier": "https://verifier.example.org" - }, - "tool_transcript": { - "hash": "sha256:9e448a4a37c2f6c80cfe5eaaeaa374f1420fdbc34ade7f737d75704b08737226", - "call_count": 2 - }, - "cnf": { - "jwk": { - "kty": "OKP", - "crv": "Ed25519", - "x": "To0gBmGBWZZHhWaNFEaBEAyR0pBToKLXiKCb2edaCqc" - } - }, - "signature": "n5R1SK2pY98WZcE-nSTovJiwVHD6O6nMWQ_GK_L0VdaIHxhHTwXYQr8-QnEJA2K1a9prtkznQ-V5MKxBvHDuCQ" - }, - "transcript": { - "format": "example-only/mcp-retry-transcript-v1", - "canonicalization": "example-only/jcs-safe-integers-v1", - "execution_scope": "synthetic-scope-1", - "attempts": [ - { - "attempt_id": "attempt-1", - "retry_of": null, - "request_context": "http-post-1", - "server_observed": "https://inventory.example.org/mcp", - "protocol_revision": "2026-07-28", - "request": { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "inventory.lookup", - "arguments": { - "record_id": "fictional-7" - }, - "_meta": { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientCapabilities": {}, - "io.modelcontextprotocol/clientInfo": { - "name": "example", - "version": "1" - }, - "traceparent": "00-11111111111111111111111111111111-2222222222222222-01" - } - } - }, - "declarations": { - "digest": "sha256:93bdb7facd1ee883e13a95a2215b2e676639ae6232d9ae1702d0b17b57302ee3", - "canonicalization": "example-only/jcs-safe-integers-v1" - }, - "observation": { - "kind": "response_stream_lost" - } - }, - { - "attempt_id": "attempt-2", - "retry_of": "attempt-1", - "request_context": "http-post-2", - "server_observed": "https://inventory.example.org/mcp", - "protocol_revision": "2026-07-28", - "request": { - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": { - "name": "inventory.lookup", - "arguments": { - "record_id": "fictional-7" - }, - "_meta": { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientCapabilities": {}, - "io.modelcontextprotocol/clientInfo": { - "name": "example", - "version": "1" - }, - "traceparent": "00-11111111111111111111111111111111-2222222222222222-01" - } - } - }, - "declarations": { - "digest": "sha256:a156121db05f2980eb5394aeeca364492cb43f678b4c0d487fe8954b00a7dac1", - "canonicalization": "example-only/jcs-safe-integers-v1" - }, - "observation": { - "kind": "response_observed", - "response": { - "jsonrpc": "2.0", - "id": 2, - "result": { - "resultType": "complete", - "isError": false, - "content": [ - { - "type": "text", - "text": "Fictional stock: 3" - } - ], - "structuredContent": { - "stock": 3 - } - } - } - } - } - ] - }, - "snapshots": { - "sha256:93bdb7facd1ee883e13a95a2215b2e676639ae6232d9ae1702d0b17b57302ee3": { - "format": "example-only/mcp-declarations-v1", - "canonicalization": "example-only/jcs-safe-integers-v1", - "server_observed": "https://inventory.example.org/mcp", - "protocol_revision": "2026-07-28", - "capture_id": "capture-before-first-dispatch", - "source": "fresh-discovery", - "collection_complete_observed": true, - "catalog_change_observed_during_collection": false, - "catalog_change_observed_before_dispatch": false, - "pages": [ - { - "request_cursor": null, - "result": { - "resultType": "complete", - "tools": [ - { - "name": "inventory.lookup", - "description": "Look up a fictional stock record.", - "inputSchema": { - "type": "object", - "properties": { - "record_id": { - "type": "string" - } - }, - "required": [ - "record_id" - ], - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "properties": { - "stock": { - "type": "integer" - } - }, - "required": [ - "stock" - ], - "additionalProperties": false - }, - "annotations": { - "readOnlyHint": true - } - } - ], - "nextCursor": "page-2", - "ttlMs": 0, - "cacheScope": "private" - } - }, - { - "request_cursor": "page-2", - "result": { - "resultType": "complete", - "tools": [ - { - "name": "inventory.reserve", - "description": "Uncalled tool, retained in full.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "_meta": { - "example.org/declaration-revision": "one" - } - } - ], - "ttlMs": 0, - "cacheScope": "private" - } - } - ] - }, - "sha256:a156121db05f2980eb5394aeeca364492cb43f678b4c0d487fe8954b00a7dac1": { - "format": "example-only/mcp-declarations-v1", - "canonicalization": "example-only/jcs-safe-integers-v1", - "server_observed": "https://inventory.example.org/mcp", - "protocol_revision": "2026-07-28", - "capture_id": "capture-before-retry-dispatch", - "source": "fresh-discovery", - "collection_complete_observed": true, - "catalog_change_observed_during_collection": false, - "catalog_change_observed_before_dispatch": false, - "pages": [ - { - "request_cursor": null, - "result": { - "resultType": "complete", - "tools": [ - { - "name": "inventory.lookup", - "description": "Look up a fictional stock record.", - "inputSchema": { - "type": "object", - "properties": { - "record_id": { - "type": "string" - } - }, - "required": [ - "record_id" - ], - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "properties": { - "stock": { - "type": "integer" - } - }, - "required": [ - "stock" - ], - "additionalProperties": false - }, - "annotations": { - "readOnlyHint": true - } - } - ], - "nextCursor": "page-2", - "ttlMs": 0, - "cacheScope": "private" - } - }, - { - "request_cursor": "page-2", - "result": { - "resultType": "complete", - "tools": [ - { - "name": "inventory.reserve", - "description": "Uncalled tool, retained in full.", - "inputSchema": { - "type": "object" - }, - "outputSchema": { - "type": "object" - }, - "_meta": { - "example.org/declaration-revision": "two" - } - } - ], - "ttlMs": 0, - "cacheScope": "private" - } - } - ] - } - } -} diff --git a/examples/mcp-retry/verification-inputs.json b/examples/mcp-retry/verification-inputs.json deleted file mode 100644 index 551079ad..00000000 --- a/examples/mcp-retry/verification-inputs.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "trusted_key": { - "kty": "OKP", - "crv": "Ed25519", - "x": "To0gBmGBWZZHhWaNFEaBEAyR0pBToKLXiKCb2edaCqc" - }, - "now": 1790294400 -} diff --git a/spec/mcp-profile-v0.3-draft.md b/spec/mcp-profile-v0.3-draft.md index 5264a904..462072d9 100644 --- a/spec/mcp-profile-v0.3-draft.md +++ b/spec/mcp-profile-v0.3-draft.md @@ -120,7 +120,7 @@ attempts, and MUST NOT be presented as counts of business operations completed. These are required test designs, not claims of tests already implemented. -The informative [MCP retry example](../examples/mcp-retry/README.md) illustrates a +The executable [MCP retry example](../examples/mcp-retry/README.md) illustrates a subset using a signed v0.2 record and an explicitly local evidence format. Its tests do not implement this proposed profile or settle the choices in section 7. diff --git a/tests/test_adequacy_all_sets.py b/tests/test_adequacy_all_sets.py index bb20fc04..e0596d30 100644 --- a/tests/test_adequacy_all_sets.py +++ b/tests/test_adequacy_all_sets.py @@ -236,10 +236,6 @@ def test_the_loader_reads_a_different_set_for_each_name() -> None: # That is the defect these criteria exist to catch, so leaving it in the instrument is # the one place it could not be caught. MEASURED_ELSEWHERE = { - # An informative packet, not a normative accept/reject conformance corpus. - "mcp-retry": "tests/test_mcp_retry_example.py, which separately checks the " - "fixture observations, record signature, full transcript/snapshot " - "commitments, tampering and missing evidence", # Not loadable here: every vector verifies, and the outcomes are per-surface # resolvability rather than accept/reject, so `trivially_satisfied_by` would # grade the set as passable by an implementation that accepts everything. diff --git a/tests/test_mcp_retry_example.py b/tests/test_mcp_retry_example.py index 40a5912b..a22a62c6 100644 --- a/tests/test_mcp_retry_example.py +++ b/tests/test_mcp_retry_example.py @@ -3,7 +3,11 @@ from __future__ import annotations import hashlib +import os import runpy +import shutil +import subprocess +import sys from pathlib import Path import pytest @@ -13,18 +17,55 @@ from agentrust_trace import key_to_jwk, verify_record -DIRECTORY = Path(__file__).resolve().parents[1] / "examples" / "mcp-retry" -GENERATOR = runpy.run_path(str(DIRECTORY / "gen_mcp_retry.py")) +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "examples" / "mcp-retry" / "mcp_retry.py" +GENERATOR = runpy.run_path(str(SCRIPT)) + + +def _generate(directory): + script = directory / SCRIPT.name + shutil.copyfile(SCRIPT, script) + output = directory / "out" + subprocess.run( + [sys.executable, str(script), "--out", str(output)], + cwd=directory, + env={**os.environ, "PYTHONPATH": str(ROOT / "src")}, + check=True, + capture_output=True, + text=True, + ) + return output + + +@pytest.fixture(scope="module") +def generated(tmp_path_factory): + return _generate(tmp_path_factory.mktemp("mcp-retry")) @pytest.fixture -def packet(): - return GENERATOR["load_json"]((DIRECTORY / "packet.json").read_text("utf-8")) +def packet(generated): + return GENERATOR["load_json"]((generated / "packet.json").read_text("utf-8")) @pytest.fixture -def inputs(): - return GENERATOR["load_json"]((DIRECTORY / "verification-inputs.json").read_text("utf-8")) +def inputs(generated): + return GENERATOR["load_json"]((generated / "verification-inputs.json").read_text("utf-8")) + + +def test_script_reproduces_fixed_evidence_bytes(generated, tmp_path): + regenerated = _generate(tmp_path) + expected = { + "packet.json": "631937f5743da55eeba706a6f9dd812b92d5ff4adb0b8589e03d6548e76fda65", + "verification-inputs.json": ( + "56a8a25ea79823110ff61b6f09ca418a3fa6ab6fc57bcb4232e7b3d97ac1fe06" + ), + } + assert {path.name for path in generated.iterdir()} == set(expected) + assert {path.name for path in regenerated.iterdir()} == set(expected) + for name, digest in expected.items(): + content = (generated / name).read_bytes() + assert content == (regenerated / name).read_bytes() + assert hashlib.sha256(content).hexdigest() == digest def _verify_record(packet, inputs): From d51f44311449b64a72626b12b60f8e19890280e0 Mon Sep 17 00:00:00 2001 From: Noah Ingwers <98993329+noah-ing@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:58:01 -0500 Subject: [PATCH 4/5] examples: tighten MCP retry fixture invariants Signed-off-by: Noah Ingwers <98993329+noah-ing@users.noreply.github.com> --- examples/mcp-retry/README.md | 4 +++- examples/mcp-retry/mcp_retry.py | 10 ++++++---- tests/test_mcp_retry_example.py | 15 +++++++++++---- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/examples/mcp-retry/README.md b/examples/mcp-retry/README.md index ef350e1b..74cf68d2 100644 --- a/examples/mcp-retry/README.md +++ b/examples/mcp-retry/README.md @@ -14,7 +14,9 @@ pytest tests/test_mcp_retry_example.py The script writes `packet.json` and separate `verification-inputs.json` (trusted key and fixed evaluation time). These generated files are not committed; the script is the single source. Tests reproduce them twice and pin their exact bytes, then check -the signature with the separate trusted key and independently recompute commitments. +the signature with the separate trusted key and recompute commitments without the +generator's helper. Both paths use `rfc8785`; this is not independent validation of +that library's canonicalization. Outputs use explicit UTF-8/LF bytes on every platform. The deterministic public signing seed is test material with no real-world identity. | Observation | Meaning | diff --git a/examples/mcp-retry/mcp_retry.py b/examples/mcp-retry/mcp_retry.py index dfc1302a..44e66009 100644 --- a/examples/mcp-retry/mcp_retry.py +++ b/examples/mcp-retry/mcp_retry.py @@ -132,9 +132,11 @@ def packet() -> dict[str, Any]: after = snapshot("capture-before-retry-dispatch") # Same selected tool. The changed declaration is deliberately an uncalled tool. after["pages"][1]["result"]["tools"][0]["_meta"]["example.org/declaration-revision"] = "two" - snapshots = {digest(s): s for s in (before, after)} + captures = [(digest(s), s) for s in (before, after)] + snapshots = dict(captures) + assert len(snapshots) == 2, "example requires two distinct snapshot digests" attempts = [] - for index, declaration_digest in enumerate(snapshots, start=1): + for index, (declaration_digest, _) in enumerate(captures, start=1): request = { "jsonrpc": "2.0", "id": index, @@ -219,8 +221,8 @@ def main() -> None: "verification-inputs.json": {"trusted_key": key_to_jwk(fixture_key()), "now": NOW}, } for name, value in artifacts.items(): - (directory / name).write_text( - json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + (directory / name).write_bytes( + (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8") ) diff --git a/tests/test_mcp_retry_example.py b/tests/test_mcp_retry_example.py index a22a62c6..658aa822 100644 --- a/tests/test_mcp_retry_example.py +++ b/tests/test_mcp_retry_example.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import json import os import runpy import shutil @@ -81,7 +82,8 @@ def _verify_record(packet, inputs): def _commitment(value, expected): - # Independent of the generator's digest helper. This is not a semantic appraisal. + # Separate from the generator's helper, but shares its rfc8785 dependency. + # Neither independent canonicalizer validation nor a semantic appraisal. if value is None: return "unavailable" actual = "sha256:" + hashlib.sha256(rfc8785.dumps(value)).hexdigest() @@ -131,11 +133,13 @@ def test_retry_retains_the_lost_response_without_inventing_an_execution_outcome( def test_both_full_paginated_captures_survive_with_the_uncalled_tool_change(packet): + assert len(packet["snapshots"]) == 2 before, after = [ packet["snapshots"][attempt["declarations"]["digest"]] for attempt in packet["transcript"]["attempts"] ] - assert before["capture_id"] != after["capture_id"] + assert before["capture_id"] == "capture-before-first-dispatch" + assert after["capture_id"] == "capture-before-retry-dispatch" for snapshot in (before, after): first, last = snapshot["pages"] assert snapshot["source"] == "fresh-discovery" @@ -238,6 +242,7 @@ def test_duplicate_json_members_are_refused_before_commitment(text): GENERATOR["load_json"](text) +@pytest.mark.parametrize("entrypoint", ["load_json", "digest"]) @pytest.mark.parametrize( "number", [ @@ -251,9 +256,11 @@ def test_duplicate_json_members_are_refused_before_commitment(text): "-9007199254740992", ], ) -def test_example_numeric_subset_is_explicitly_refused(number): +def test_example_numeric_subset_is_explicitly_refused(number, entrypoint): + text = '{"nested":[' + number + "]}" + value = text if entrypoint == "load_json" else json.loads(text) with pytest.raises(ValueError, match="unsupported numeric value"): - GENERATOR["load_json"]('{"nested":[' + number + "]}") + GENERATOR[entrypoint](value) def test_local_canonicalization_preserves_supported_boolean_null_and_safe_integer(): From 389459ca1f469b800676e696f653f8ace5f0d600 Mon Sep 17 00:00:00 2001 From: Noah Ingwers <98993329+noah-ing@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:33:35 -0500 Subject: [PATCH 5/5] test: enforce MCP example numeric domain when matching commitments Signed-off-by: Noah Ingwers <98993329+noah-ing@users.noreply.github.com> --- examples/mcp-retry/README.md | 6 ++++-- tests/test_mcp_retry_example.py | 23 ++++++++++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/examples/mcp-retry/README.md b/examples/mcp-retry/README.md index 74cf68d2..cc423bd2 100644 --- a/examples/mcp-retry/README.md +++ b/examples/mcp-retry/README.md @@ -43,8 +43,10 @@ distinguish the two hash domains. members, all floats and integers outside `[-9007199254740991, 9007199254740991]`; preserve booleans, null and array order. No coercion or dropped members. Identifiers are local, not registered formats; JSON whitespace is not committed. Missing evidence -is unavailable, not a match; changed retained evidence mismatches even if the record -signature still verifies. Tests cover both boundaries, wrong keys and signed-field edits. +is unavailable, not a match; unsupported numeric values report `unsupported` before +hash comparison, even with an otherwise matching raw-JCS digest. Changed retained +evidence mismatches even if the record signature still verifies. Tests cover these +boundaries, wrong keys and signed-field edits. All observations are producer assertions, not authenticated server responses. Enforcement is `declared`, appraisal `none`, and runtime/model/build fields synthetic. diff --git a/tests/test_mcp_retry_example.py b/tests/test_mcp_retry_example.py index 658aa822..d7d2d47e 100644 --- a/tests/test_mcp_retry_example.py +++ b/tests/test_mcp_retry_example.py @@ -86,6 +86,15 @@ def _commitment(value, expected): # Neither independent canonicalizer validation nor a semantic appraisal. if value is None: return "unavailable" + pending = [value] + while pending: + item = pending.pop() + if isinstance(item, float) or (type(item) is int and abs(item) > 9007199254740991): + return "unsupported" + if isinstance(item, dict): + pending.extend(item.values()) + elif isinstance(item, list): + pending.extend(item) actual = "sha256:" + hashlib.sha256(rfc8785.dumps(value)).hexdigest() return "matched" if actual == expected else "mismatch" @@ -242,7 +251,7 @@ def test_duplicate_json_members_are_refused_before_commitment(text): GENERATOR["load_json"](text) -@pytest.mark.parametrize("entrypoint", ["load_json", "digest"]) +@pytest.mark.parametrize("entrypoint", ["load_json", "digest", "_commitment"]) @pytest.mark.parametrize( "number", [ @@ -258,12 +267,24 @@ def test_duplicate_json_members_are_refused_before_commitment(text): ) def test_example_numeric_subset_is_explicitly_refused(number, entrypoint): text = '{"nested":[' + number + "]}" + if entrypoint == "_commitment": + assert _commitment(json.loads(text), "sha256:" + "0" * 64) == "unsupported" + return value = text if entrypoint == "load_json" else json.loads(text) with pytest.raises(ValueError, match="unsupported numeric value"): GENERATOR[entrypoint](value) +@pytest.mark.parametrize("number", [1.0, 1.5, -0.0]) +def test_unsupported_number_never_matches_even_its_raw_jcs_digest(number): + retained = {"nested": [{"x": number}]} + expected = "sha256:" + hashlib.sha256(rfc8785.dumps(retained)).hexdigest() + assert _commitment(retained, expected) == "unsupported" + + def test_local_canonicalization_preserves_supported_boolean_null_and_safe_integer(): text = '{"values":[true,false,null,9007199254740991,-9007199254740991]}' value = GENERATOR["load_json"](text) assert GENERATOR["canonical_bytes"](value) == text.encode() + expected = "sha256:" + hashlib.sha256(text.encode()).hexdigest() + assert _commitment(value, expected) == "matched"