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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
4 changes: 4 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
56 changes: 56 additions & 0 deletions examples/mcp-retry/README.md
Original file line number Diff line number Diff line change
@@ -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.
230 changes: 230 additions & 0 deletions examples/mcp-retry/mcp_retry.py
Original file line number Diff line number Diff line change
@@ -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()
4 changes: 4 additions & 0 deletions spec/mcp-profile-v0.3-draft.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading
Loading