diff --git a/CHANGELOG.md b/CHANGELOG.md index cddf64f3..e0029e52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,11 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. behaviour, signature or outcome is affected. Informative only: no schema, wire-format or normative change. +- 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. + ## [0.11.0] - 2026-09-25 - **Breaking:** `TraceSandboxAdapter` and `TraceAGTAdapter` require `enforcement_mode`; diff --git a/examples/README.md b/examples/README.md index ec3c0789..5b94115c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -60,6 +60,10 @@ checking any archived record, including these. not accepted normative text (#247); carries a `scan_published_signatures.py` tool that reports how many published signatures are already canonical. See that directory's README. +- [`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. 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..cc423bd2 --- /dev/null +++ b/examples/mcp-retry/README.md @@ -0,0 +1,56 @@ +# Lost response, retry, changed declarations + +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 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 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 | +|---|---| +| 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; 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. +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/mcp_retry.py b/examples/mcp-retry/mcp_retry.py new file mode 100644 index 00000000..44e66009 --- /dev/null +++ b/examples/mcp-retry/mcp_retry.py @@ -0,0 +1,230 @@ +"""Write an informative, synthetic MCP retry packet, not a v0.3 profile. + +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 +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.""" + 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) + + +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" + 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(captures, 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": declaration_digest, + "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: + 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}, + } + for name, value in artifacts.items(): + (directory / name).write_bytes( + (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8") + ) + + +if __name__ == "__main__": + main() diff --git a/spec/mcp-profile-v0.3-draft.md b/spec/mcp-profile-v0.3-draft.md index 1f09e3a0..462072d9 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 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. + | 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_mcp_retry_example.py b/tests/test_mcp_retry_example.py new file mode 100644 index 00000000..d7d2d47e --- /dev/null +++ b/tests/test_mcp_retry_example.py @@ -0,0 +1,290 @@ +"""Check the worked example's retained facts and commitments, not an MCP verifier.""" + +from __future__ import annotations + +import hashlib +import json +import os +import runpy +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest +import rfc8785 +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from agentrust_trace import key_to_jwk, verify_record + +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(generated): + return GENERATOR["load_json"]((generated / "packet.json").read_text("utf-8")) + + +@pytest.fixture +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): + 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 _commitment(value, expected): + # 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" + 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" + + +def test_record_and_full_retained_commitments_verify_separately(packet, inputs): + _verify_record(packet, inputs) + 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" + ) + assert snapshot["server_observed"] == attempt["server_observed"] + assert snapshot["protocol_revision"] == attempt["protocol_revision"] + + +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 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): + assert len(packet["snapshots"]) == 2 + before, after = [ + packet["snapshots"][attempt["declarations"]["digest"]] + for attempt in packet["transcript"]["attempts"] + ] + 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" + 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,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( + "path,value", + [ + (("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_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) + 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 _commitment(snapshot, reference) == "mismatch" + + +@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"): + _verify_record(packet, inputs) + + +def test_changing_the_signed_transcript_hash_invalidates_the_signature(packet, inputs): + packet["record"]["tool_transcript"]["hash"] = "sha256:" + "0" * 64 + with pytest.raises(InvalidSignature): + _verify_record(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("entrypoint", ["load_json", "digest", "_commitment"]) +@pytest.mark.parametrize( + "number", + [ + "1.0", + "1e0", + "0.5", + "NaN", + "Infinity", + "-Infinity", + "9007199254740992", + "-9007199254740992", + ], +) +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"