diff --git a/.changeset/bright-requests-cancel.md b/.changeset/bright-requests-cancel.md new file mode 100644 index 00000000..4be4789d --- /dev/null +++ b/.changeset/bright-requests-cancel.md @@ -0,0 +1,6 @@ +--- +'@atrib/daemon': minor +--- + +Add per-request bearer verification and rate limiting, abort read primitives on +cancellation, and preserve late write settlement through idempotent replay. diff --git a/.changeset/calm-retries-recover.md b/.changeset/calm-retries-recover.md new file mode 100644 index 00000000..c7df3405 --- /dev/null +++ b/.changeset/calm-retries-recover.md @@ -0,0 +1,6 @@ +--- +'@atrib/daemon': patch +--- + +Carry modern request metadata into duplicate-write binding, and prove discovery, +reads, receipts, and replay across hard process replacement. diff --git a/.changeset/slow-demons-retry.md b/.changeset/slow-demons-retry.md new file mode 100644 index 00000000..f5555ac2 --- /dev/null +++ b/.changeset/slow-demons-retry.md @@ -0,0 +1,5 @@ +--- +"@atrib/daemon": patch +--- + +Add real-process in-flight kill coverage and enforce local request-path latency budgets. diff --git a/.changeset/warm-pens-smoke.md b/.changeset/warm-pens-smoke.md new file mode 100644 index 00000000..e316aef8 --- /dev/null +++ b/.changeset/warm-pens-smoke.md @@ -0,0 +1,5 @@ +--- +"@atrib/mcp-wrap": patch +--- + +Run the filesystem smoke through the stable MCP 2026-07-28 client. diff --git a/.gitleaks.toml b/.gitleaks.toml index 16985071..e753c55b 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -74,7 +74,7 @@ paths = [ [[allowlists]] description = "atrib protocol field names, never credentials" regexes = [ - '''(creator_key|revoked_key|successor_key|lookup_for_key|signing_key|key_thumbprint|signature|chain_root|content_id|record_hash|provenance_token|session_token)''', + '''(creator_key|revoked_key|successor_key|lookup_for_key|signing_key|key_thumbprint|signature|chain_root|content_id|record_hash|provenance_token|session_token|dev\.atrib/idempotencyKey)''', ] # regexTarget = "line" matches the entire flagged line. The atrib protocol # field names appear as JSON keys NEXT TO their high-entropy values, not diff --git a/CLAUDE.md b/CLAUDE.md index e5fa25fe..9f195455 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,9 +52,9 @@ atrib/ CLAUDE.md # THIS FILE: hub doc, conventions, invariants DESIGN.md # Product design system source of truth: current state, target state, tokens, components, surface backlog atrib-spec.md # The single source of truth for the protocol - DECISIONS.md # Architectural decision log (D001-D184; D070 accepts the Record Body Archive Layer per §2.12; D073 remains the placeholder ADR for the `handoff` event_type byte; D071 codifies spec writing conventions; D072 governs orphan handling; D074 + D075 capture the git-trailer integration and compose-not-override hook config patterns; D076 introduces the opt-in long-lived atrib-emit daemon; D077 codifies pass^k as the primary Track B reporting metric, k=3 default, promoted from P019 on 2026-05-10; D081 introduces emitInProcess for hook-class producers (signing in-process, byte-identical to MCP-signed records); D082 supersedes D081's integration shape by shipping `atrib-emit-cli` from `@atrib/emit` and having the hook helper spawn the binary, so the hook source directory never becomes an npm workspace; D083 extends D078 with a harness session-id discovery registry in `@atrib/mcp` so cognitive-primitive MCP servers spawned by Claude Code, and future harnesses, derive `context_id` from the parent's session_id env var without operator-side config (v2 2026-05-23 adds optional file-fallback to the registry for startup-spawn harnesses like Claude Code whose MCP children pre-date the per-session env; SessionStart hook writes `~/.claude/state/active-session-id-` and `resolveEnvContextId` reads it when env unset); D084 ships per-event instrumentation jsonl files under `~/.atrib/state/` for read primitives (Surface 6, via `logReadPrimitiveCall` in `@atrib/mcp@0.10.0`), SessionStart (Surface 7), cli-spawn transport (Surface 8), plus a unified analyzer (Surface 9, host-side) that joins all four jsonl pillars + the signed-record mirror so the loop-closure question can be answered against data instead of inferred from one source; `@atrib/recall` compact-mode response now always carries `record_hash` so callers can chain other primitives from any result; D085/D086 tune recall calibration and BM25 content indexing; D087 formalizes signed diagnostic outcome + trace replay as the canonical repair/refinement pattern; D088 updates AP2 transaction detection to successful CheckoutReceipt / PaymentReceipt as the current v0.2 hook while keeping the v0.1 PaymentMandate DataPart fallback; D089 adds verifier-side AP2 / Verifiable Intent evidence checks in `@atrib/verify`; D090 adds async AP2 receipt JWT verification with `jose`, trusted JWKS, and verifier metadata; D091 adds async AP2 / VI SD-JWT conformance with OpenWallet `sd-jwt-js`; D092 adds typed AP2 / VI mandate constraint evaluation; D093 makes the AP2 / VI fixture directory the local verifier corpus; D094 attaches AP2 / VI evidence to verifier results as a tiered block; D095 adds an AP2 Path 2 content_id receipt identity ladder; D096 adds a pinned offline AP2 / VI crypto conformance corpus and named verifier hardening for JOSE, JWKS, SD-JWT, and clock edges; D097 adds an opt-in AP2 live interop artifact harness in `@atrib/integration`; D098 keeps AP2 receipt signatures as external evidence and makes Path 2 transaction records carry an agent `signers[]` entry via `signTransactionRecord`; D099 commits explicit emit content through default `args_hash` and gives direct CLI emits a default mirror path; D100 lets MCP middleware sign and run `onRecord` with log submission disabled for offline tests and local-mirror-only hosts; D101 adds the substrate-wide adversarial conformance corpus for §1.4 signing, §3.2.4 full edge derivation, D067 race vectors, and D052 creator-signer separation; D102 requires sandboxed producers to keep Ed25519 signing keys outside the sandbox through a host signer proxy; D103 adds optional SSE and JSON Feed subscription surfaces over commitment-visible fields; D104 codifies `ATRIB_PARENT_RECORD_HASH` parent-child threading through `informed_by`; D105 adds verifier-side Pattern 3 handoff claim acceptance in `@atrib/verify`; D106 promotes `@atrib/verify-mcp` as cognitive primitive #7 after two independent Pattern 3 receiving flows; D107 adds AP2 counterparty attestation over atrib transaction bytes and makes `cross_attestation.signers_valid` count distinct verified keys; D108 makes OpenTelemetry/OpenInference span trees an intake/correlation layer and puts detailed observability fields in local sidecar content for recall, trace, and summarize; D109 adds generic tiered authorization evidence blocks, MCP/OAuth evidence checks, and resolved capability facts to `@atrib/verify`; D110 closes producer-side MCP/OAuth evidence capture, DPoP proof checks, the local harness, and the §5.5.6 OAuth corpus; D111 adds the archive evidence API, producer-side archive submission, HTTP-backed DPoP replay-cache adapter, and host-owned OAuth introspection helper; D112 adds `@atrib/memory-tool` as an Anthropic Memory Tool handler wrapper that signs memory commands while leaving storage host-owned; D113 makes producers omit unvalidated `informed_by` refs by default while keeping `allow_unresolved_informed_by` for deliberate dangling claims; D114 adds the Google ADK Python plugin proof at the Python tool callback boundary; D115 defines the three-signal same-session agent-to-subagent env bundle; D116 adds source-aware producer-side `informed_by` validation; D117 classifies demo/proof record treatment by execution surface; D118 makes the explorer primary trace path a presentation rule over trace plus chain while keeping human-attested approval as separate signed evidence; D119 keeps AAuth as verifier-side authorization evidence with a producer capture helper and conformance corpus; D120 accepts the optional local substrate coordinator pattern while keeping startup-spawn signing, sidecars, and outbound context wrapper-owned; D121 accepts runtime-log proof manifests for host-owned run windows while keeping raw runtime logs outside the public log and trace systems as projections; D122 keeps host runtime adapters distinct from agent framework adapters and pins one signing owner per host event; D123 makes critical-path content recall require complete evidence or explicit fallback; D124 makes base recall context scope explicit; D125 makes complete content recall coverage-first; D126 adds the durable content index; D127 gates primitive runtime health on recall contract freshness; D128 makes host-owned primitive runtime updates a build, restart, and direct-probe command; D129 gates primitive runtime health on every mounted primitive package and tool surface; D130 gates primitive runtime health on deterministic non-mutating behavioral probes while write primitives stay skipped until validate-only contracts exist; D131 adds the Google ADK decision-ledger proof at the before-tool authority boundary; D132 keeps x401 proof requirements as verifier-side authorization evidence rather than payment detection; D133 adds `@atrib/action-gate` as the host-owned decision and outcome proof package for high-impact actions; D134 adds x401 producer capture, local proof-gate and multi-endpoint E2E, sanitized archive projection, Explorer propagation, optional origin, trust, proof-payment binding facts, released Proof SDK native interop, Proof VC credential-verifier evidence, and the Proof upstream open-thread map; D135 threads delegated-builder atrib context via orchestrator-injected explicit `context_id` + `informed_by` rather than ambient env/file, since the builder's atrib runtime is a shared persistent process; D136 adds the consolidated client SDKs: `@atrib/sdk` with attest/recall verbs, daemon-first with in-process fallback and no third signing implementation, plus the Python `atrib` distribution as the first non-TypeScript §1/§5 implementation, held byte-identical through the shared conformance corpora and a cross-implementation determinism harness, both in-repo with an extraction-ready layout; D137 makes the universal evidence envelope the single attachment model for external evidence per §5.5.7; D138 generalizes cross-log replication to anchor plurality per §2.11.7-§2.11.13; D139 adds the session_checkpoint event type extension-first per §1.2.10 with byte 0x08 reserved; D140 adds delegation certificates, principal keys certifying ephemeral run keys, per §1.11; D141 publishes the dev.atrib/attribution MCP extension v0.1 per §1.5.4.1 and docs/extensions/dev.atrib-attribution/; D142 codifies orchestration-topology baton-pass and join records as conventional attest content shapes and registers the continuation-packet evidence profile, the ninth atrib-maintained §5.5.7 profile; D143 evaluates authority as verifier-side policy over informed_by lineage with minimum-along-path propagation, closing the laundering gap without adding record fields or edge types; D144 adds the pre-action elevation gate in @atrib/action-gate composing D143 authority evaluation with the D052/D107 cross-attestation distinctness rule; D145 adds action-bound single-use authorization tokens over a JCS-plus-SHA-256 five-field binding with a host-owned atomic consumption store; D146 widens mirror inheritance from the effective file to the corpus-scoped directory, deterministic cross-file tie-breaks by signed timestamp then canonical hash, with a deletable advisory tail index; D147 spins the payment-rail material out of the spec core into the independently versioned payments profile at docs/payments-profile.md, leaving stable tombstone anchors behind and registering the payments-detection and payments-settlement evidence profiles; D148 promotes the primitive runtime to atribd, the public stateless-native local daemon, built behind a transport adapter with per-context write serialization and the seven-family conformance corpus at spec/conformance/atribd/; D149 composes cross-attestation with a caller-supplied trust set so untrusted co-signers surface as sybil_suspected instead of counting as authority; D150 generalizes attestation as corroboration off transactions to any signed target, extension-first per §8.7.6; D151 makes own signed content win over chain-derived text in rendering; D152 makes handoff verdicts receiver-computed; D153 gives chain expansion a reserved budget share; D154 separates memory retrieval selection from expansion; D155 makes write-primitive refusals error-shaped; D156 makes tool-call score suppression liftable per query; D157 renders revision lineage as an ordered connected chain; D158 renders dropped chain members as a compact ordered path line; D159 lets the lineage tail win budget admission; D160 carries temporal provenance on rendered memory lines; D161 links cross-topic revisions under a stricter content bar; D162 keeps factual values untruncated in rendered memory; D163 ships the session-transcript runtime-log source that binds harness transcript windows (Claude-Code-style session JSONL, the Session Traces Format shape; D164 collapses the primitive surface to the attest/recall verbs with the fifteen legacy tool names as permanent aliases, byte-identical records, and the alias-window migration rules) to signed records through manifest receipts, fork and compaction bindings, and durable-body refs; v2 adds the auto-detected codex-rollout-jsonl profile; D165 wires D142 baton/join signing into the operator.s orchestration routing plane with per-leg routing facts in content/sidecar, adds the `subagent` role term, and gives §6.7.1 envelopes / §1.11 certificate scopes an optional cost_policy (model_tiers, max_tokens) as a signal-only signed budget grant; D166 makes verifiable-action the @atrib/mcp-wrap default, with signed tool, argument, and result commitments plus linked request/outcome records for pre-call receipt injection). A "Pending decisions" section at the end (forward-looking pattern) tracks forward-looking decisions awaiting action (P002, P004, P005, P008, P009, P010, P012, P013, P016-P018, P021, P024, P026, P027, P036-P040, P047, P052, P053, P054-P058). P012, P013, P016, and P017 cover remaining runtime and sandboxing patterns. P018 and P021 cover eval-framework adoption and benchmark publication. P024, P026, and P027 cover spec hosting, multi-creator SessionStart, and host-side hook deployment. P036-P040 cover the support/RCA implication set from the Autumn support-investigation case study: cross-harness continuation packets, skill/context provenance, hosted-agent diagnostics, the support/RCA demo wedge, and Mastra source verification. P047 remains from the 2026-07 redesign upgrade-path candidate set (attest/recall rename; full drafts under `docs/adr-draft-p04x-*.md`, plan in `docs/redesign-upgrade-path.md`); P042-P045 and P049 were promoted to D137-D141 on 2026-07-06; P050 was promoted to D142 on 2026-07-08; P048 was promoted to D147 and P046 to D148 on 2026-07-10. P051 was promoted to D165 on 2026-07-15. P053 holds multi-head revision merge semantics behind an independent-application gate. P054-P058 port the benchmark-derived substrate findings first filed on 2026-07-17, renumbered from P043-P047 because those numbers were promoted in the interim: the hidden-superseded prior_position projection leak, question-form recall calibration, attest indexability warnings, supersession trigger policy beyond agent errors, and the local-mirror-only SDK mode ADR. P052 holds the transcript recall corpus composition-first: manifests and extraction receipts are the interface to memory tools, with explicit attribution triggers for ever revisiting. + DECISIONS.md # Architectural decision log (D001-D187; D070 accepts the Record Body Archive Layer per §2.12; D073 remains the placeholder ADR for the `handoff` event_type byte; D071 codifies spec writing conventions; D072 governs orphan handling; D074 + D075 capture the git-trailer integration and compose-not-override hook config patterns; D076 introduces the opt-in long-lived atrib-emit daemon; D077 codifies pass^k as the primary Track B reporting metric, k=3 default, promoted from P019 on 2026-05-10; D081 introduces emitInProcess for hook-class producers (signing in-process, byte-identical to MCP-signed records); D082 supersedes D081's integration shape by shipping `atrib-emit-cli` from `@atrib/emit` and having the hook helper spawn the binary, so the hook source directory never becomes an npm workspace; D083 extends D078 with a harness session-id discovery registry in `@atrib/mcp` so cognitive-primitive MCP servers spawned by Claude Code, and future harnesses, derive `context_id` from the parent's session_id env var without operator-side config (v2 2026-05-23 adds optional file-fallback to the registry for startup-spawn harnesses like Claude Code whose MCP children pre-date the per-session env; SessionStart hook writes `~/.claude/state/active-session-id-` and `resolveEnvContextId` reads it when env unset); D084 ships per-event instrumentation jsonl files under `~/.atrib/state/` for read primitives (Surface 6, via `logReadPrimitiveCall` in `@atrib/mcp@0.10.0`), SessionStart (Surface 7), cli-spawn transport (Surface 8), plus a unified analyzer (Surface 9, host-side) that joins all four jsonl pillars + the signed-record mirror so the loop-closure question can be answered against data instead of inferred from one source; `@atrib/recall` compact-mode response now always carries `record_hash` so callers can chain other primitives from any result; D085/D086 tune recall calibration and BM25 content indexing; D087 formalizes signed diagnostic outcome + trace replay as the canonical repair/refinement pattern; D088 updates AP2 transaction detection to successful CheckoutReceipt / PaymentReceipt as the current v0.2 hook while keeping the v0.1 PaymentMandate DataPart fallback; D089 adds verifier-side AP2 / Verifiable Intent evidence checks in `@atrib/verify`; D090 adds async AP2 receipt JWT verification with `jose`, trusted JWKS, and verifier metadata; D091 adds async AP2 / VI SD-JWT conformance with OpenWallet `sd-jwt-js`; D092 adds typed AP2 / VI mandate constraint evaluation; D093 makes the AP2 / VI fixture directory the local verifier corpus; D094 attaches AP2 / VI evidence to verifier results as a tiered block; D095 adds an AP2 Path 2 content_id receipt identity ladder; D096 adds a pinned offline AP2 / VI crypto conformance corpus and named verifier hardening for JOSE, JWKS, SD-JWT, and clock edges; D097 adds an opt-in AP2 live interop artifact harness in `@atrib/integration`; D098 keeps AP2 receipt signatures as external evidence and makes Path 2 transaction records carry an agent `signers[]` entry via `signTransactionRecord`; D099 commits explicit emit content through default `args_hash` and gives direct CLI emits a default mirror path; D100 lets MCP middleware sign and run `onRecord` with log submission disabled for offline tests and local-mirror-only hosts; D101 adds the substrate-wide adversarial conformance corpus for §1.4 signing, §3.2.4 full edge derivation, D067 race vectors, and D052 creator-signer separation; D102 requires sandboxed producers to keep Ed25519 signing keys outside the sandbox through a host signer proxy; D103 adds optional SSE and JSON Feed subscription surfaces over commitment-visible fields; D104 codifies `ATRIB_PARENT_RECORD_HASH` parent-child threading through `informed_by`; D105 adds verifier-side Pattern 3 handoff claim acceptance in `@atrib/verify`; D106 promotes `@atrib/verify-mcp` as cognitive primitive #7 after two independent Pattern 3 receiving flows; D107 adds AP2 counterparty attestation over atrib transaction bytes and makes `cross_attestation.signers_valid` count distinct verified keys; D108 makes OpenTelemetry/OpenInference span trees an intake/correlation layer and puts detailed observability fields in local sidecar content for recall, trace, and summarize; D109 adds generic tiered authorization evidence blocks, MCP/OAuth evidence checks, and resolved capability facts to `@atrib/verify`; D110 closes producer-side MCP/OAuth evidence capture, DPoP proof checks, the local harness, and the §5.5.6 OAuth corpus; D111 adds the archive evidence API, producer-side archive submission, HTTP-backed DPoP replay-cache adapter, and host-owned OAuth introspection helper; D112 adds `@atrib/memory-tool` as an Anthropic Memory Tool handler wrapper that signs memory commands while leaving storage host-owned; D113 makes producers omit unvalidated `informed_by` refs by default while keeping `allow_unresolved_informed_by` for deliberate dangling claims; D114 adds the Google ADK Python plugin proof at the Python tool callback boundary; D115 defines the three-signal same-session agent-to-subagent env bundle; D116 adds source-aware producer-side `informed_by` validation; D117 classifies demo/proof record treatment by execution surface; D118 makes the explorer primary trace path a presentation rule over trace plus chain while keeping human-attested approval as separate signed evidence; D119 keeps AAuth as verifier-side authorization evidence with a producer capture helper and conformance corpus; D120 accepts the optional local substrate coordinator pattern while keeping startup-spawn signing, sidecars, and outbound context wrapper-owned; D121 accepts runtime-log proof manifests for host-owned run windows while keeping raw runtime logs outside the public log and trace systems as projections; D122 keeps host runtime adapters distinct from agent framework adapters and pins one signing owner per host event; D123 makes critical-path content recall require complete evidence or explicit fallback; D124 makes base recall context scope explicit; D125 makes complete content recall coverage-first; D126 adds the durable content index; D127 gates primitive runtime health on recall contract freshness; D128 makes host-owned primitive runtime updates a build, restart, and direct-probe command; D129 gates primitive runtime health on every mounted primitive package and tool surface; D130 gates primitive runtime health on deterministic non-mutating behavioral probes while write primitives stay skipped until validate-only contracts exist; D131 adds the Google ADK decision-ledger proof at the before-tool authority boundary; D132 keeps x401 proof requirements as verifier-side authorization evidence rather than payment detection; D133 adds `@atrib/action-gate` as the host-owned decision and outcome proof package for high-impact actions; D134 adds x401 producer capture, local proof-gate and multi-endpoint E2E, sanitized archive projection, Explorer propagation, optional origin, trust, proof-payment binding facts, released Proof SDK native interop, Proof VC credential-verifier evidence, and the Proof upstream open-thread map; D135 threads delegated-builder atrib context via orchestrator-injected explicit `context_id` + `informed_by` rather than ambient env/file, since the builder's atrib runtime is a shared persistent process; D136 adds the consolidated client SDKs: `@atrib/sdk` with attest/recall verbs, daemon-first with in-process fallback and no third signing implementation, plus the Python `atrib` distribution as the first non-TypeScript §1/§5 implementation, held byte-identical through the shared conformance corpora and a cross-implementation determinism harness, both in-repo with an extraction-ready layout; D137 makes the universal evidence envelope the single attachment model for external evidence per §5.5.7; D138 generalizes cross-log replication to anchor plurality per §2.11.7-§2.11.13; D139 adds the session_checkpoint event type extension-first per §1.2.10 with byte 0x08 reserved; D140 adds delegation certificates, principal keys certifying ephemeral run keys, per §1.11; D141 publishes the dev.atrib/attribution MCP extension v0.1 per §1.5.4.1 and docs/extensions/dev.atrib-attribution/; D142 codifies orchestration-topology baton-pass and join records as conventional attest content shapes and registers the continuation-packet evidence profile, the ninth atrib-maintained §5.5.7 profile; D143 evaluates authority as verifier-side policy over informed_by lineage with minimum-along-path propagation, closing the laundering gap without adding record fields or edge types; D144 adds the pre-action elevation gate in @atrib/action-gate composing D143 authority evaluation with the D052/D107 cross-attestation distinctness rule; D145 adds action-bound single-use authorization tokens over a JCS-plus-SHA-256 five-field binding with a host-owned atomic consumption store; D146 widens mirror inheritance from the effective file to the corpus-scoped directory, deterministic cross-file tie-breaks by signed timestamp then canonical hash, with a deletable advisory tail index; D147 spins the payment-rail material out of the spec core into the independently versioned payments profile at docs/payments-profile.md, leaving stable tombstone anchors behind and registering the payments-detection and payments-settlement evidence profiles; D148 promotes the primitive runtime to atribd, the public stateless-native local daemon, built behind a transport adapter with per-context write serialization and the seven-family conformance corpus at spec/conformance/atribd/; D149 composes cross-attestation with a caller-supplied trust set so untrusted co-signers surface as sybil_suspected instead of counting as authority; D150 generalizes attestation as corroboration off transactions to any signed target, extension-first per §8.7.6; D151 makes own signed content win over chain-derived text in rendering; D152 makes handoff verdicts receiver-computed; D153 gives chain expansion a reserved budget share; D154 separates memory retrieval selection from expansion; D155 makes write-primitive refusals error-shaped; D156 makes tool-call score suppression liftable per query; D157 renders revision lineage as an ordered connected chain; D158 renders dropped chain members as a compact ordered path line; D159 lets the lineage tail win budget admission; D160 carries temporal provenance on rendered memory lines; D161 links cross-topic revisions under a stricter content bar; D162 keeps factual values untruncated in rendered memory; D163 ships the session-transcript runtime-log source that binds harness transcript windows (Claude-Code-style session JSONL, the Session Traces Format shape; D164 collapses the primitive surface to the attest/recall verbs with the fifteen legacy tool names as permanent aliases, byte-identical records, and the alias-window migration rules) to signed records through manifest receipts, fork and compaction bindings, and durable-body refs; v2 adds the auto-detected codex-rollout-jsonl profile; D165 wires D142 baton/join signing into the operator.s orchestration routing plane with per-leg routing facts in content/sidecar, adds the `subagent` role term, and gives §6.7.1 envelopes / §1.11 certificate scopes an optional cost_policy (model_tiers, max_tokens) as a signal-only signed budget grant; D166 makes verifiable-action the @atrib/mcp-wrap default, with signed tool, argument, and result commitments plus linked request/outcome records for pre-call receipt injection). A "Pending decisions" section at the end (forward-looking pattern) tracks forward-looking decisions awaiting action (P002, P004, P005, P008, P009, P010, P012, P013, P016-P018, P021, P024, P026, P027, P036-P040, P047, P052, P053, P054-P058). P012, P013, P016, and P017 cover remaining runtime and sandboxing patterns. P018 and P021 cover eval-framework adoption and benchmark publication. P024, P026, and P027 cover spec hosting, multi-creator SessionStart, and host-side hook deployment. P036-P040 cover the support/RCA implication set from the Autumn support-investigation case study: cross-harness continuation packets, skill/context provenance, hosted-agent diagnostics, the support/RCA demo wedge, and Mastra source verification. P047 remains from the 2026-07 redesign upgrade-path candidate set (attest/recall rename; full drafts under `docs/adr-draft-p04x-*.md`, plan in `docs/redesign-upgrade-path.md`); P042-P045 and P049 were promoted to D137-D141 on 2026-07-06; P050 was promoted to D142 on 2026-07-08; P048 was promoted to D147 and P046 to D148 on 2026-07-10. P051 was promoted to D165 on 2026-07-15. P053 holds multi-head revision merge semantics behind an independent-application gate. P054-P058 port the benchmark-derived substrate findings first filed on 2026-07-17, renumbered from P043-P047 because those numbers were promoted in the interim: the hidden-superseded prior_position projection leak, question-form recall calibration, attest indexability warnings, supersession trigger policy beyond agent errors, and the local-mirror-only SDK mode ADR. P052 holds the transcript recall corpus composition-first: manifests and extraction receipts are the interface to memory tools, with explicit attribution triggers for ever revisiting. # Decision-log continuation: D167 ships external-operator-ready witness software; D168 adds runtime-bound coverage manifests; D169 adds one-time protected MCP execution permits; D170 adds fail-closed checkpoint gossip and immutable split-view incidents; D171 adds one-command named identity plus explicit scope and trusted-time verification state; D172 publishes prior-run revocation before successor activation and propagates verified revocation views into verifiers and protected executors; D173 makes log position the sole revocation-order evidence and surfaces missing order as unverifiable; D174 adds bounded policy-state and public revision-state projections that keep forks unresolved and state their different verification bases; D175 adds exact log-index subscription cursors and native EventSource reconnect semantics; D176 makes the explorer distinguish compact log commitments from retrievable signed record bodies; D177 adds the paired SDK action-evidence path; D178 ships the open operating-graph application profile and reference client; D179 registers separate Nostr and Buzz evidence profiles and repairs the verifier registry drift; D180 gives directory anchors a durable linear commit journal with exact-body reconciliation; D181 makes directory consistency follow the verifier's exact previously accepted anchor; D182 binds witness acceptance to endpoint, trust root, liveness, and operator epoch; D183 separates cursor-bound runtime observation from durable host acceptance and semantic promotion; D184 makes stateless MCP writes duplicate-safe through action-bound idempotency keys and durable result replay. - # Decision-log current head: D185 makes TypeScript and Python clients carry complete per-request MCP context and expose negotiated attribution. + # Decision-log current head: D187 keeps one atribd daemon per operator profile. ARCHITECTURE.md # Technical architecture overview: trust model, protocol layers, design decisions PRIOR-ART.md # Prior art & standards map: every spec/protocol atrib builds on, organized by layer METRICS.md # Tiered metrics framework + lifecycle states + quarterly evolution review for the dogfood experiment docs/publishing-new-npm-package.md # Runsheet for creating and publishing a new public npm package. @@ -67,6 +67,7 @@ atrib/ docs/evidence-profiles/ # §5.5.7 (D137) profile documents for the thirteen atrib-maintained evidence-envelope profiles, including Nostr and Buzz event evidence per D179. docs/extensions/dev.atrib-attribution/ # dev.atrib/attribution MCP extension specification (P049): v0.1 extension document (identifier, capability settings, prefixed _meta carriage, attestation receipts, negotiation, degradation, versioning per SEP-2133). docs/website-redesign-relay.md # Relay brief for the website-overhaul session: approved P042-P050 facts that change public language, vocabulary table, embargoes/timeline, and the governance rules copy changes are bound by. + docs/stateless-mcp-attribution-guide.md # Developer guide for native servers, wrappers, SDK clients, request receipts, retry behavior, and independent verification under stateless MCP. docs/ots-pending-receipt-worker.md # Host-owned OTS pending-receipt worker contract and a sample launchd schedule. It never installs a LaunchAgent. docs/attest-recall-rename-impact.md # Blast-radius catalog for the attest/recall verb rename (executed as D164): npm packages, MCP tool names, persisted producer labels, signed-bytes analysis, operator machine state, and doc/skill surfaces. docs/traces-integration-research.md # Research doc (2026-07-14, not an ADR): agent-trace landscape across harnesses, the four objects called "trace", overlap and difference with the D121/D122 runtime-log layer, dogfood trace-surface inventory, and decision candidates (session-transcript RuntimeLogSource adapter recommended; transcript recall corpus held for its own ADR). diff --git a/DECISIONS.md b/DECISIONS.md index 0e8d6107..82015759 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -9568,6 +9568,119 @@ localhost endpoint: can sign fixture records into operator state. **Protocol impact.** None. The change implements existing MCP 2026-07-28 and `dev.atrib/attribution` v0.1 carriage. It changes no atrib signed byte. +## D186: Stateless MCP security and cancellation are request-scoped + +**Date:** 2026-07-29 + +**Status:** Accepted and implemented + +**Extends:** [D169](#d169-protected-mcp-execution-requires-a-one-time-server-side-permit), +[D184](#d184-stateless-mcp-writes-use-action-bound-idempotency-keys), and +[D185](#d185-client-sdks-carry-complete-stateless-mcp-request-context). + +**Context.** Removing MCP sessions also removes the connection as a valid +place to keep authorization, rate-limit, or cancellation state. A reused HTTP +connection says nothing about who sent the next request. A client name is +self-asserted metadata. Cancellation creates a second problem for writes: the +caller can leave after the signing side effect starts, so "cancelled" cannot +mean "nothing happened." + +**Decision.** + +1. atribd can require the MCP SDK's bearer verifier on every MCP POST. The + verifier checks token validity, expiry, required scopes, and operator-owned + revocation state before dispatch. Health stays unauthenticated so a process + supervisor can diagnose a broken verifier. +2. Verified `authInfo` is request-local. The rate-limit hook receives the + client id, scopes, expiry, resource, and optional verified attributes. It + never receives the bearer token. atribd does not write authentication + material into signed records or daemon diagnostics. +3. The rate-limit hook runs once per POST. It receives the MCP method, tool + name when present, protocol era, and action class. It cannot key authority + from a socket, session id, or client name. +4. Incoming cancellation reaches atribd through the official MCP handler + signal. Read calls forward that signal to the mounted primitive. A timeout + aborts the same path, releases the routed request, and records the outcome + in daemon diagnostics. +5. A write is not aborted after dispatch. Its caller receives the cancellation + or timeout error, while the per-context lock stays held until the primitive + settles. A late success completes the [D184](#d184-stateless-mcp-writes-use-action-bound-idempotency-keys) + entry and becomes replayable. Diagnostics distinguish late settlement after + cancellation from late settlement after timeout. +6. One-time execution permits remain bound to the exact action through + [D169](#d169-protected-mcp-execution-requires-a-one-time-server-side-permit). + Write retry keys remain bound to context, tool, and complete arguments + through [D184](#d184-stateless-mcp-writes-use-action-bound-idempotency-keys). + Missing, changed, expired, replayed, and revoked cases fail before the + protected dispatch boundary. +7. atribd does not use MCP `requestState` for authority or routing. Any future + multi-round tool that does so must configure the SDK verification hook, + integrity-protect the value, set an expiry, and bind it to the authenticated + principal and method. + +**Guarantee boundary.** The default local daemon still binds loopback without +bearer authentication. Operators that expose it beyond a trusted local process +boundary must configure bearer verification and a request limiter. Cancellation +releases read-request resources, but a dispatched write can still complete. Its +idempotency state and late-settlement counters are the evidence for that +uncertain interval. + +**Alternatives rejected.** Trust `clientInfo.name`: the caller controls it. +Authorize once per connection: HTTP connection reuse crosses requests and +principals. Abort every timed-out write: the side effect may already have +happened, and forgetting it permits a duplicate retry. Pass raw bearer tokens +to the limiter: the limiter needs verified identity facts, not reusable +credentials. Accept raw `requestState`: caller-carried continuation state has +no authority without integrity and binding checks. + +**Protocol impact.** None. This applies MCP 2026-07-28 request lifecycle and +OAuth hooks to atribd. It changes no atrib record field or signed byte. + +## D187: Keep one atribd daemon per operator profile + +**Date:** 2026-07-29 + +**Status:** Accepted and implemented + +**Context.** Stateless MCP allows any request for one security domain to reach +any instance configured for that domain. It does not make separate signing, +storage, or policy domains interchangeable. The operator machine runs +`claude-code`, `claude-desktop`, and `codex` profiles. Each has a distinct +loopback port, `ATRIB_AGENT`, mirror file, autochain source, and local-substrate +endpoint. + +**Decision.** Keep one atribd process per operator profile. Do not add shared +profile routing to the daemon. + +The current process boundary provides: + +1. one fixed signing and Keychain scope per process; +2. one mirror, autochain source, idempotency store, and coordinator route; +3. profile-labeled health, logs, restarts, and rollback; +4. independent failure and resource-pressure domains; and +5. no caller-controlled profile selector to authenticate or misroute. + +**Evidence.** The three installed LaunchAgents differ on ports 8795, 8792, and +8796. They set profile-specific `ATRIB_AGENT`, `ATRIB_MIRROR_FILE`, +`ATRIB_AUTOCHAIN_SOURCE`, and `ATRIB_LOCAL_SUBSTRATE_ENDPOINT` values. atribd +does not implement authenticated per-request selection for that complete +resource set or a scheduler that prevents one profile from starving another. + +**Revisit gate.** A future shared supervisor requires authenticated profile +identity, key and mirror non-interference tests, profile-scoped idempotency and +serialization, explicit authorization policy, per-profile health and logs, +fairness limits, and a one-command rollback to separate daemons. Process-count +reduction alone is not enough. + +**Alternatives rejected.** One fully shared daemon would turn a routing bug +into a signing or storage boundary violation. Isolated workers behind one +supervisor add lifecycle coupling without removing the worker processes that +provide isolation. The current three-daemon topology is small and already +operable through the profile-aware runtime updater. + +**Protocol impact.** None. This is a deployment boundary. It changes no MCP +message and no atrib signed byte. + # Pending decisions These will get full ADRs when we act on them. Recorded here so they remain findable and don't silently drop. Per the global Deferred Decision Logging convention, this section uses the forward-looking pattern (forward-looking decisions that will become numbered ADRs when codified). diff --git a/README.md b/README.md index 5b763bdb..99001816 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,20 @@ second `tool_call` record. The private integration package pins this rule in [`packages/integration/src/host-runtime-proof.ts`](packages/integration/src/host-runtime-proof.ts) so future OpenClaw, Hermes, and other host proofs share the same vocabulary. +## Stateless MCP attribution + +Add verifiable records to a tool call without adopting a new agent runtime or +maintaining an atrib transport session. A native server can advertise +`dev.atrib/attribution`; an existing server can sit behind `@atrib/mcp-wrap`; +and the TypeScript and Python SDKs carry the complete request envelope +automatically. The tool result can include a signed receipt that a verifier +checks without trusting atrib's hosted service. + +The [stateless MCP attribution guide](docs/stateless-mcp-attribution-guide.md) +shows the request, result, receipt, integration choices, runnable proofs, retry +rules, and the boundary between proof of a signed claim and proof that the +claim is true. + ## How it works - Each record is signed by the actor's Ed25519 key and JCS-canonicalized diff --git a/STATUS.md b/STATUS.md index 432ef651..6a98add8 100644 --- a/STATUS.md +++ b/STATUS.md @@ -26,6 +26,10 @@ The package inventory, grouped current / deprecated / private, lives in the [REA - Record signing ([§1](atrib-spec.md#1-attribution-record-format)) including `INFORMED_BY` ([D041](DECISIONS.md#d041-informed_by-linking-primitive-and-informed_by-edge-type)), `PROVENANCE_OF` ([D044](DECISIONS.md#d044-provenance_token-field-for-cross-session-causal-anchoring)), ANNOTATES ([D058](DECISIONS.md#d058-promote-annotation-to-atrib-normative-event_type-byte-0x05)), REVISES ([D059](DECISIONS.md#d059-promote-revision-to-atrib-normative-event_type-byte-0x06)), and `directory_anchor` ([D056](DECISIONS.md#d056-promote-directory_anchor-to-atrib-normative-event_type-byte-0x04)) event types. - Native MCP v2 transport coverage ([D148](DECISIONS.md#d148-atribd-is-the-public-stateless-native-local-daemon-for-the-primitive-runtime)): the 13 atrib-owned MCP entrypoints negotiate protocol `2026-07-28`, including 12 published surfaces across standalone stdio binaries, atribd's three transport modes, and `atrib-wrap`. A machine-readable inventory binds each entrypoint to its process proof. Doc sync checks source ownership, while release readiness builds the packages and inspects every packed public artifact. Upstream simulation fixtures stay explicitly excluded. Health probes use a collision-resistant absent context so valid operator data cannot turn a healthy runtime into a false degradation. - Stateless client request carriage ([D185](DECISIONS.md#d185-client-sdks-carry-complete-stateless-mcp-request-context)): `@atrib/sdk` and the Python `atrib` package declare `dev.atrib/attribution` by default, carry explicit context plus W3C and compatibility metadata on each daemon request, advance valid receipt tokens, and expose negotiated protocol and extension facts. Python includes an independent standard-library MCP 2026-07-28 HTTP client. Both implementations consume the same request-carriage corpus. +- Request-scoped daemon security and lifecycle ([D186](DECISIONS.md#d186-stateless-mcp-security-and-cancellation-are-request-scoped)): atribd can verify OAuth bearer tokens and required scopes on every MCP POST, then apply a per-request limiter using verified principal facts without exposing the token. Read cancellation reaches the primitive. Dispatched writes keep settling under their context lock, and diagnostics separate cancellation, timeout, and late settlement. Connection reuse, session headers, client names, and raw `requestState` grant no authority. +- Stateless process replacement: a real fixed-endpoint test hard-kills atribd, starts a fresh process, and proves modern discovery, reads, negotiated receipts, and one-record completed-write replay without session repair. Modern v2 request metadata reaches the same context and idempotency policy as legacy requests. The concurrency proof runs 32 independent contexts and keeps one context linear while another progresses. +- Independent attribution-extension interoperability: Python's standard-library client consumes atribd. A separate standard-library Python server imports no atrib or MCP package and is consumed by `@atrib/sdk`; the client checks modern negotiation and verifies the receipt token, record hash, creator key, context, chain root, and Ed25519 signature against the language-neutral corpus. +- Profile topology ([D187](DECISIONS.md#d187-keep-one-atribd-daemon-per-operator-profile)): the three operator profiles remain separate atribd processes. Each process owns one signing scope, mirror, autochain source, idempotency store, coordinator route, health surface, and rollback target. Stateless request routing applies within a profile security domain, not across profiles. - Parent-child producer threading now has a single same-session subagent env bundle: `ATRIB_CONTEXT_ID`, `ATRIB_CHAIN_TAIL_`, and `ATRIB_PARENT_RECORD_HASH`, built by `@atrib/mcp` `buildSubagentProducerEnv()` when the parent dispatch hash is known before the child signs ([D104](DECISIONS.md#d104-parent-child-threading-uses-atrib_parent_record_hash), [D115](DECISIONS.md#d115-agent-to-subagent-handoff-uses-a-three-signal-producer-bundle)). - Source-aware producer validation lets `@atrib/mcp-wrap` keep configured `informedByPaths` only when refs resolve through the wrapper mirror, local mirrors, or log lookup, while parent env seeds stay producer-owned ([D116](DECISIONS.md#d116-producer-side-informed_by-validation-is-source-aware)). - Sandboxed producer key isolation ([§1.4.6](atrib-spec.md#146-signing-key-isolation-for-sandboxed-execution), [D102](DECISIONS.md#d102-sandboxed-signer-proxy-keeps-keys-outside-sandbox)) keeps signing keys outside sandboxed execution via a tested host signer-proxy example. diff --git a/atrib-spec.md b/atrib-spec.md index 16bb1057..dc2b6798 100644 --- a/atrib-spec.md +++ b/atrib-spec.md @@ -3458,6 +3458,12 @@ serverInfo: { ### 5.4 @atrib/agent: Agent Middleware +In this section, "session state" means application-owned continuity across an +agent run. It is not MCP transport-session state. Each MCP request still carries +its complete protocol, identity, capability, trace, and atrib context metadata. +No rule in [§5.4](#54-atribagent-agent-middleware) depends on a connection, +`initialize`, or `Mcp-Session-Id`. + #### 5.4.1 Init Interface The agent middleware exposes an interception surface that the host application or MCP client integrates at outbound tool call and inbound tool response boundaries. This is a framework-agnostic design; the middleware does not monkey-patch a specific agent implementation, because no single agent shape covers the LangChain / Mastra / AI SDK / direct-MCP-client landscape. Instead, the middleware returns an interceptor object that the caller hooks into their own request/response lifecycle. diff --git a/docs/extensions/dev.atrib-attribution/independent-server.py b/docs/extensions/dev.atrib-attribution/independent-server.py new file mode 100644 index 00000000..5458f0c9 --- /dev/null +++ b/docs/extensions/dev.atrib-attribution/independent-server.py @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Minimal MCP 2026-07-28 attribution server using only Python's stdlib. + +Run from the repository root: + +python3 docs/extensions/dev.atrib-attribution/independent-server.py \ + spec/conformance/mcp-extension/cases/receipt--consistent.json +""" + +from __future__ import annotations + +import json +import signal +import socketserver +import sys +from http.server import BaseHTTPRequestHandler +from pathlib import Path +from typing import Any, cast + +receipt_case = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +receipt_block = receipt_case["input"]["result_block"] + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: # noqa: N802 + length = int(self.headers.get("Content-Length", "0")) + body = cast(dict[str, Any], json.loads(self.rfile.read(length))) + method = body["method"] + if method == "server/discover": + result: dict[str, Any] = { + "supportedVersions": ["2026-07-28"], + "capabilities": { + "tools": {}, + "extensions": { + "dev.atrib/attribution": { + "version": "0.1", + "receipts": ["token", "record"], + } + }, + }, + "ttlMs": 0, + "cacheScope": "private", + "resultType": "complete", + } + elif method == "tools/call": + meta = cast(dict[str, Any], body["params"]["_meta"]) + declaration = cast( + dict[str, Any], + cast(dict[str, Any], meta["io.modelcontextprotocol/clientCapabilities"])[ + "extensions" + ]["dev.atrib/attribution"], + ) + if declaration["version"] != "0.1": + self.send_error(400) + return + result = { + "content": [{"type": "text", "text": json.dumps({"ok": True})}], + "_meta": {"dev.atrib/attribution": receipt_block}, + "resultType": "complete", + } + else: + self.send_error(404) + return + encoded = json.dumps( + {"jsonrpc": "2.0", "id": body["id"], "result": result}, + separators=(",", ":"), + ).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def log_message(self, _format: str, *_args: object) -> None: + return + + +class Server(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + daemon_threads = True + + +server = Server(("127.0.0.1", 0), Handler) +signal.signal(signal.SIGTERM, lambda _signum, _frame: server.shutdown()) +host, port = server.server_address +print(json.dumps({"endpoint": f"http://{host}:{port}/mcp"}), flush=True) +server.serve_forever() diff --git a/docs/extensions/dev.atrib-attribution/v0.1.md b/docs/extensions/dev.atrib-attribution/v0.1.md index 1d766543..bbb15191 100644 --- a/docs/extensions/dev.atrib-attribution/v0.1.md +++ b/docs/extensions/dev.atrib-attribution/v0.1.md @@ -450,6 +450,21 @@ SHOULD validate against the corpus; the vectors are the shared contract between independent implementations in any language, with no atrib package required. +### 10.1 Runnable independent-server proof + +[`independent-server.py`](independent-server.py) is a minimal MCP 2026-07-28 +server written against this document. It uses Python's standard library and +imports no atrib or MCP package. It declares the extension, checks the client's +per-request declaration, and returns the real signed receipt from the +language-neutral corpus. + +The `@atrib/sdk` test suite starts that server as a separate process, consumes +it with the TypeScript daemon client, checks modern protocol negotiation, and +verifies the token, record hash, creator key, context, chain root, and Ed25519 +signature. The opposite direction uses Python's standard-library +`StatelessMcpClient` against atribd. Together they prove independent client and +server interoperability across both language and implementation boundaries. + ## 11. References - atrib protocol specification: : record format ([§1.2](../../../atrib-spec.md#12-the-attribution-record)), canonical serialization ([§1.3](../../../atrib-spec.md#13-canonical-serialization)), propagation token ([§1.5.2](../../../atrib-spec.md#152-http-transport-tracestate)), MCP transport ([§1.5.4](../../../atrib-spec.md#154-mcp-transport-params_meta)), multi-producer chain composition ([§1.2.3.1](../../../atrib-spec.md#1231-multi-producer-chain-composition)), log submission ([§5.3.5](../../../atrib-spec.md#535-log-submission)), degradation contract ([§5.8](../../../atrib-spec.md#58-degradation-contract)), salted-commitment posture ([§8.3](../../../atrib-spec.md#83-salted-commitment-posture)). diff --git a/docs/payments-profile.md b/docs/payments-profile.md index bde440a7..5b507c41 100644 --- a/docs/payments-profile.md +++ b/docs/payments-profile.md @@ -470,7 +470,7 @@ The session policy record MUST include a warning: `"transaction_emitted_by_agent AP2 receipt JWT signatures and Verifiable Intent credentials are not Path 2 counterparty signers. They remain verifier evidence unless the AP2 participant also returns a signature over the atrib transaction record bytes. See [D098](../DECISIONS.md#d098-ap2-receipts-stay-external-evidence-for-cross-attestation). -**Path selection rule:** preventing double-emission.\*\* The agent middleware MUST NOT emit a transaction record (Path 2) when the checkout tool response contains an attribution context token (i.e., `params._meta.atrib`, `tracestate: atrib=...`, or `X-atrib-Chain` is present in the response). The presence of an attribution token in the checkout response indicates that `@atrib/mcp` is installed on the merchant's server and has already emitted the transaction record (Path 1). Emitting a second record would create two transaction nodes for the same economic event, violating the single-transaction-per-session assumption in [§8.1](#81-inputs-and-preconditions). When Path 1 is detected, the agent updates its session state with the inbound context token as normal, but skips transaction record emission. +**Path selection rule:** preventing double-emission.\*\* The agent middleware MUST NOT emit a transaction record (Path 2) when the checkout tool response contains an attribution context token (i.e., `params._meta.atrib`, `tracestate: atrib=...`, or `X-atrib-Chain` is present in the response). The presence of an attribution token in the checkout response indicates that `@atrib/mcp` is installed on the merchant's server and has already emitted the transaction record (Path 1). Emitting a second record would create two transaction nodes for the same economic event, violating the single-transaction-per-economic-event rule in [§8.1](#81-inputs-and-preconditions). When Path 1 is detected, the agent updates its application-owned continuity state with the inbound context token as normal, but skips transaction record emission. This state is not an MCP transport session; the token arrives on the response and is carried explicitly on the next request. In both paths, when Path 2 is taken, the record MUST be submitted to the log immediately, because the transaction event is the closing anchor of the attribution graph. diff --git a/docs/stateless-mcp-attribution-guide.md b/docs/stateless-mcp-attribution-guide.md new file mode 100644 index 00000000..33fd94fe --- /dev/null +++ b/docs/stateless-mcp-attribution-guide.md @@ -0,0 +1,165 @@ +# Add verifiable records to stateless MCP calls + +An atrib MCP server is a request-shaped proof service, not a +conversation-shaped process. A complete request carries its own protocol +version, client capabilities, attribution context, trace context, and write +retry identity. The server can restart between calls without requiring the +client to repair a transport session. + +This model lets an existing tool call gain a signed result receipt without +moving the agent loop into a new runtime: + +1. The client sends an ordinary MCP tool request with + `dev.atrib/attribution` in request metadata. +2. The server runs the tool and signs the selected action facts. +3. The result carries the tool content plus a machine-readable receipt. +4. Any verifier can check the attached record locally with the creator's + Ed25519 public key. + +The signed record is durable application state. Stateless transport means the +server does not depend on a prior connection, initialize exchange, or session +header. It does not mean the action has no history. + +## Run the proofs + +From this repository: + +```bash +# build atribd and its workspace dependency closure +pnpm --filter '@atrib/daemon...' build + +# atribd through the stable MCP 2026-07-28 client +pnpm --filter @atrib/daemon test -- -t \ + "negotiates and lists tools with the stable v2 client" + +# atrib's TypeScript client through an independent Python server +pnpm --filter @atrib/sdk test -- independent-mcp-server.test.ts + +# the standard-library Python client request and receipt path +python -m pip install -e 'python[dev]' +python -m pytest python/tests/test_mcp_client.py + +# any upstream MCP server through the wrapper boundary +pnpm --filter @atrib/mcp-wrap smoke:filesystem +``` + +The independent server imports no atrib package and no MCP package. The test +verifies discovery, request negotiation, the propagation token, record hash, +creator key, context id, chain root, and Ed25519 signature. The wrapper smoke +starts a real filesystem MCP server and checks that its normal result still +arrives with a signed mirror record. + +The language-neutral request, receipt, degradation, and negative fixtures live +under +[`spec/conformance/mcp-extension/`](../spec/conformance/mcp-extension/). +The extension contract and a standalone server are in +[`docs/extensions/dev.atrib-attribution/`](extensions/dev.atrib-attribution/). + +## Native server path + +Use the native path when you own the MCP server. Advertise +`dev.atrib/attribution` in `server/discover`, read its request metadata on each +call, and emit a receipt only when that request negotiated the extension. +[`@atrib/mcp`](../packages/mcp/README.md) supplies the signing middleware and +receipt helpers. + +The request must be complete at the boundary: + +```json +{ + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": { + "name": "charge_card", + "arguments": { "invoice_id": "inv-7" } + }, + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "billing-agent", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": { + "extensions": { + "dev.atrib/attribution": { + "version": "0.1", + "accept": ["token", "record"] + } + } + }, + "dev.atrib/attribution": { + "context_id": "0123456789abcdef0123456789abcdef" + }, + "dev.atrib/idempotencyKey": "invoice7" + } +} +``` + +The exact wire schema is defined by the +[`dev.atrib/attribution` v0.1 document](extensions/dev.atrib-attribution/v0.1.md). +Use a stable action-bound idempotency key when retrying a write after a timeout +or disconnect. Reusing a key with changed arguments is an error. + +## Existing server path + +Use [`@atrib/mcp-wrap`](../packages/mcp-wrap/README.md) when you do not own the +upstream server. Point the host at `atrib-wrap` instead of the upstream binary. +The agent loop and tool schema stay unchanged. The wrapper exposes a native +MCP 2026-07-28 boundary, signs request and outcome commitments, and can talk to +an older upstream MCP implementation behind that boundary. + +The outer boundary can therefore be native v2 while the wrapped dependency +remains a compatibility component. Operational reports must keep those two +facts separate. + +## Client SDK path + +Use [`@atrib/sdk`](../packages/sdk/README.md) or the +[`atrib` Python SDK](../python/README.md) for daemon-backed `attest` and +`recall`. Both clients send the full stateless metadata envelope by default, +negotiate attribution receipts, expose the observed transport facts, and use +the same write idempotency model. + +Application code that owns a non-MCP execution boundary can use the TypeScript +SDK's `action()` helper. It signs a request, executes the existing function, +then signs a linked success or failure outcome. It does not replace or own the +agent loop. + +## Integration map + +| Existing system | Integration point | Runnable proof | +| --------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| Tool gateway | Native `dev.atrib/attribution` server capability or an `@atrib/mcp-wrap` outer boundary | `pnpm --filter @atrib/mcp-wrap smoke:filesystem` | +| Agent framework | `@atrib/agent` callback middleware, `@atrib/openinference` span intake, or MCP middleware | The framework examples cataloged in the [root README](../README.md#examples-and-proofs) | +| Cloud agent | The same request metadata and receipt contract over a hosted Streamable HTTP endpoint | [`packages/integration/examples/cloudflare-agents/`](../packages/integration/examples/cloudflare-agents/) | +| Commerce system | Signed tool request and outcome plus payment or authorization evidence selected by the host | [`docs/payments-profile.md`](payments-profile.md) and the x402/AP2 examples in the [root README](../README.md#examples-and-proofs) | +| Audit system | Store or forward the receipt, then verify the record and any disclosed evidence independently | `pnpm --filter @atrib/sdk test -- independent-mcp-server.test.ts` | + +## What the receipt proves + +A valid receipt proves that the holder of the named creator key signed the +attached record bytes and that the record commits to the disclosed action +facts. A log inclusion proof can also prove that the commitment was accepted at +a particular log position. + +The receipt alone does not prove that a tool result is true, that a side effect +occurred, that the signer was authorized, or that a real-world counterparty +agreed. Those claims need the relevant host, authorization, tool-side, +counterparty, or transparency-log evidence. atrib keeps those evidence checks +separate so a valid signature cannot be mistaken for independent truth. + +## Operations + +- Retry reads as complete new requests. +- Retry writes with the same complete arguments and idempotency key. +- Treat an indeterminate write as unresolved until the same key returns a + completed result. +- Cache `tools/list` only for its advertised `ttlMs` and `cacheScope`. +- Diagnose protocol version, request metadata, routing headers, result metadata, + and daemon health. Do not repair a removed transport session. +- Keep signing keys, mirrors, idempotency state, and authorization policy + isolated by operator profile. + +The detailed daemon evidence packet and restart procedure are in the +[`atribd` runbook](../services/atribd/README.md#request-diagnosis). diff --git a/docs/stateless-mcp-v2-follow-through.md b/docs/stateless-mcp-v2-follow-through.md index eedfbd85..18737993 100644 --- a/docs/stateless-mcp-v2-follow-through.md +++ b/docs/stateless-mcp-v2-follow-through.md @@ -388,16 +388,17 @@ The follow-through program is complete when: | 0B: compatibility observability | release pending | atribd persists bounded profile, client, protocol, and era observations; health and topology agree; the updater rejects modern-to-legacy regressions; the live profiles still need the released package | | 1A: duplicate-safe writes | release pending | [D184](../DECISIONS.md#d184-stateless-mcp-writes-use-action-bound-idempotency-keys) defines action-bound keys, durable completed-result replay, indeterminate pending outcomes, seven-day retention, and lock-through-settlement; daemon and TS SDK implementation needs release and live proof | | 1B: automatic request carriage | release pending | [D185](../DECISIONS.md#d185-client-sdks-carry-complete-stateless-mcp-request-context) adds one shared metadata builder, default receipt negotiation, explicit write context, W3C and legacy carriers, exposed transport facts, and an independent Python v2 HTTP client. The shared corpus, Python mock daemon, and both clients pass live atribd reads. | -| 1C: request-scoped security and lifecycle | not started | Existing permit and authorization components must be audited against the stateless request boundary | -| 2A: resilience and performance | not started | Existing tests cover parts of concurrency and transport behavior, not the full failure matrix | -| 2B: independent interoperability | not started | atribd and atrib clients interoperate; independence gate remains | -| 2C: human-facing propagation | in progress | Cutover docs are current; the wider session-era scan and runbooks remain | -| 3A: daemon consolidation study | deferred | Begins only after tranches 1 and 2 | -| 3B: product propagation | deferred | Begins after released SDK and interop proof | +| 1C: request-scoped security and lifecycle | release pending | [D186](../DECISIONS.md#d186-stateless-mcp-security-and-cancellation-are-request-scoped) applies bearer verification and rate limiting per request, forwards cancellation to reads, preserves write settlement and idempotency after caller abandonment, and rejects connection or client-name authority. Existing protected-permit tests cover missing, changed, expired, replayed, and revoked dispatch. | +| 2A: resilience and performance | complete | Real fixed-endpoint tests hard-kill atribd, retry discovery, tool listing, reads, and negotiated-receipt writes, prove one-record idempotent replay, exercise 32 concurrent contexts and busy-context isolation, pin private TTL carriage, and enforce broad local request-path latency budgets. | +| 2B: independent interoperability | complete | Python's standard-library client interoperates with atribd. A separate stdlib-only Python server imports no atrib or MCP package and is consumed by the TypeScript SDK, which verifies modern negotiation plus token, record hash, creator key, context, chain root, and Ed25519 signature against the language-neutral corpus. | +| 2C: human-facing propagation | complete | The daemon runbook diagnoses bounded requests, separates read and write retries, covers restart recovery, and states the wrapper boundary. The spec now distinguishes application-owned agent continuity from removed MCP transport sessions; the payments profile no longer frames duplicate prevention as one transaction per transport session. | +| 3A: daemon consolidation study | complete | [D187](../DECISIONS.md#d187-keep-one-atribd-daemon-per-operator-profile) keeps one daemon per profile. Live LaunchAgents have distinct ports, agent identities, mirrors, autochain sources, and coordinator routes; atribd has no authenticated complete-profile selector or fairness scheduler. | +| 3B: product propagation | complete | The root README and stateless MCP attribution guide map the native server, wrapper, TypeScript, Python, gateway, framework, cloud-agent, commerce, and audit paths to runnable proofs. The guide shows request, result, receipt, retry rules, durable-state framing, independent verification, and the signed-claim truth boundary. | ## Immediate next slice -Land and release Tranches 0B, 1A, and 1B. Install the released daemon and SDKs -on the three operator profiles. Confirm modern traffic with no post-modern -legacy regression. Run the duplicate-write proof matrix against the installed -daemon, then begin the request-scoped security and lifecycle audit. +Merge the completed follow-through tranches, publish the daemon, MCP, wrapper, +and SDK packages plus the Python distribution, then install the released +daemon on the three operator profiles. Confirm modern traffic with no +post-modern legacy regression, then run the duplicate-write, cancellation, +restart, and receipt proofs against the installed daemon. diff --git a/packages/mcp-wrap/examples/filesystem-smoke.ts b/packages/mcp-wrap/examples/filesystem-smoke.ts index 49009326..97bbc3b5 100644 --- a/packages/mcp-wrap/examples/filesystem-smoke.ts +++ b/packages/mcp-wrap/examples/filesystem-smoke.ts @@ -13,8 +13,8 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { canonicalRecord, hexEncode, sha256, type AtribRecord } from '@atrib/mcp' -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import { Client } from '@modelcontextprotocol/client' +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio' const EVENT_TYPE_TOOL_CALL = 'https://atrib.dev/v1/types/tool_call' @@ -148,7 +148,10 @@ async function main(): Promise { const recordFile = join(tempDir, 'records.jsonl') const logFile = join(tempDir, 'wrapper.log') const localLog = await startLocalLog() - const client = new Client({ name: 'mcp-wrap-smoke-host', version: '0.1.0' }) + const client = new Client( + { name: 'mcp-wrap-smoke-host', version: '0.1.0' }, + { versionNegotiation: { mode: { pin: '2026-07-28' } } }, + ) try { mkdirSync(fixtureDir, { recursive: true }) @@ -190,6 +193,11 @@ async function main(): Promise { try { await client.connect(transport) + if (client.getNegotiatedProtocolVersion() !== '2026-07-28') { + throw new Error( + `expected MCP 2026-07-28, got ${client.getNegotiatedProtocolVersion() ?? 'none'}`, + ) + } const tools = await client.listTools() const toolNames = new Set(tools.tools.map((tool) => tool.name)) if (!toolNames.has('read_file')) { diff --git a/packages/mcp/src/proxy.ts b/packages/mcp/src/proxy.ts index 1ddb81b1..c34703f4 100644 --- a/packages/mcp/src/proxy.ts +++ b/packages/mcp/src/proxy.ts @@ -92,7 +92,7 @@ export interface AtribProxyOptions { */ name: string - /** Server version reported via the MCP initialize handshake. Defaults to '0.0.0'. */ + /** Server version reported through MCP discovery metadata. Defaults to '0.0.0'. */ version?: string /** Upstream MCP server transport. what the proxy forwards calls to. */ diff --git a/packages/sdk/test/independent-mcp-server.test.ts b/packages/sdk/test/independent-mcp-server.test.ts new file mode 100644 index 00000000..52bfe61e --- /dev/null +++ b/packages/sdk/test/independent-mcp-server.test.ts @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { verifyRecord, type AtribRecord } from '@atrib/mcp' +import { DaemonClient } from '../src/daemon.js' + +const ROOT = resolve(__dirname, '..', '..', '..') +const SERVER = resolve( + ROOT, + 'docs', + 'extensions', + 'dev.atrib-attribution', + 'independent-server.py', +) +const RECEIPT = resolve( + ROOT, + 'spec', + 'conformance', + 'mcp-extension', + 'cases', + 'receipt--consistent.json', +) +const CONTEXT_ID = '5e'.repeat(16) +let child: ChildProcessWithoutNullStreams | undefined + +afterEach(() => { + child?.kill('SIGKILL') + child = undefined +}) + +function startIndependentServer(): Promise { + return new Promise((resolveReady, rejectReady) => { + child = spawn('python3', [SERVER, RECEIPT], { stdio: ['pipe', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + const timer = setTimeout( + () => rejectReady(new Error(`independent server did not start: ${stderr}`)), + 10_000, + ) + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8') + }) + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8') + const newline = stdout.indexOf('\n') + if (newline < 0) return + clearTimeout(timer) + resolveReady((JSON.parse(stdout.slice(0, newline)) as { endpoint: string }).endpoint) + }) + child.once('exit', (code) => { + clearTimeout(timer) + rejectReady(new Error(`independent server exited with ${code}: ${stderr}`)) + }) + }) +} + +describe('independent dev.atrib/attribution server', () => { + it( + 'interoperates with the atrib TypeScript client without atrib server code', + { timeout: 20_000 }, + async () => { + const endpoint = await startIndependentServer() + const client = new DaemonClient( + { endpoint, connectTimeoutMs: 5_000, callTimeoutMs: 5_000 }, + { attributionAccept: ['token', 'record'] }, + ) + const outcome = await client.callTool( + 'independent_attest', + { context_id: CONTEXT_ID }, + { contextId: CONTEXT_ID }, + ) + expect(outcome.ok, outcome.ok ? undefined : outcome.reason).toBe(true) + if (!outcome.ok) return + expect(outcome.transport.protocol_version).toBe('2026-07-28') + expect(outcome.transport.protocol_era).toBe('modern') + expect(outcome.transport.attribution.declared).toBe(true) + expect(outcome.attribution?.verification).toEqual({ valid: true, mismatched: [] }) + expect(outcome.attribution?.block.receipt.context_id).toBe(CONTEXT_ID) + expect(outcome.attribution?.block.receipt.record_hash).toMatch(/^sha256:[0-9a-f]{64}$/) + expect(outcome.attribution?.block.receipt.creator_key).toBe( + outcome.attribution?.block.record?.creator_key, + ) + expect(outcome.attribution?.block.receipt.chain_root).toBe( + outcome.attribution?.block.record?.chain_root, + ) + expect(await verifyRecord(outcome.attribution?.block.record as AtribRecord)).toBe(true) + }, + ) +}) diff --git a/python/tests/test_mcp_client.py b/python/tests/test_mcp_client.py index c78d6951..e71704da 100644 --- a/python/tests/test_mcp_client.py +++ b/python/tests/test_mcp_client.py @@ -4,10 +4,11 @@ from __future__ import annotations import json +import socketserver import threading from collections.abc import Iterator from contextlib import contextmanager -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from http.server import BaseHTTPRequestHandler from pathlib import Path from typing import cast @@ -94,13 +95,18 @@ def log_message(self, format: str, *args: object) -> None: return +class _ThreadingServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + daemon_threads = True + + @contextmanager def _server( *, use_sse: bool = False ) -> Iterator[tuple[str, list[tuple[dict[str, str], dict[str, object]]]]]: _Handler.requests = [] _Handler.use_sse = use_sse - server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + server = _ThreadingServer(("127.0.0.1", 0), _Handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: diff --git a/services/atrib-primitives/src/index.ts b/services/atrib-primitives/src/index.ts index 7bc5a533..4e1367f3 100644 --- a/services/atrib-primitives/src/index.ts +++ b/services/atrib-primitives/src/index.ts @@ -45,6 +45,7 @@ interface MountedPrimitive { interface ToolRoute { primitive: string + kind: 'write' | 'read' client: ModernClient } @@ -52,6 +53,7 @@ interface InFlightToolCall { id: string primitive: string tool: string + kind: 'write' | 'read' startedAt: number timedOutAt?: number } @@ -60,10 +62,13 @@ export interface AtribPrimitivesToolCallDiagnostic { id: string primitive: string tool: string + kind: 'write' | 'read' started_at: string elapsed_ms: number timed_out: boolean timed_out_at?: string + cancelled: boolean + cancelled_at?: string } export interface AtribPrimitivesDiagnostics { @@ -73,7 +78,9 @@ export interface AtribPrimitivesDiagnostics { calls_succeeded: number calls_failed: number calls_timed_out: number + calls_cancelled: number calls_settled_after_timeout: number + calls_settled_after_cancel: number in_flight_tool_calls: AtribPrimitivesToolCallDiagnostic[] } @@ -351,9 +358,11 @@ function serializeInFlightToolCall( id: call.id, primitive: call.primitive, tool: call.tool, + kind: call.kind, started_at: new Date(call.startedAt).toISOString(), elapsed_ms: Math.max(0, now - call.startedAt), timed_out: call.timedOutAt !== undefined, + cancelled: false, } if (call.timedOutAt !== undefined) { serialized.timed_out_at = new Date(call.timedOutAt).toISOString() @@ -840,6 +849,10 @@ export async function createAtribPrimitivesBackend( let callsSettledAfterTimeout = 0 for (const primitive of mounted) { + const kind = + PRIMITIVE_SPECS.find((spec) => spec.name === primitive.name)?.mutatesLogOnCall === true + ? 'write' + : 'read' for (const tool of primitive.tools) { const existing = routeByTool.get(tool.name) if (existing) { @@ -847,7 +860,7 @@ export async function createAtribPrimitivesBackend( `duplicate atrib primitive tool ${tool.name}: ${existing.primitive} and ${primitive.name}`, ) } - routeByTool.set(tool.name, { primitive: primitive.name, client: primitive.client }) + routeByTool.set(tool.name, { primitive: primitive.name, kind, client: primitive.client }) tools.push(tool) } } @@ -872,6 +885,7 @@ export async function createAtribPrimitivesBackend( id, primitive: route.primitive, tool: request.name, + kind: route.kind, startedAt, } callsStarted += 1 @@ -973,7 +987,9 @@ export async function createAtribPrimitivesBackend( calls_succeeded: callsSucceeded, calls_failed: callsFailed, calls_timed_out: callsTimedOut, + calls_cancelled: 0, calls_settled_after_timeout: callsSettledAfterTimeout, + calls_settled_after_cancel: 0, in_flight_tool_calls: [...inFlightToolCalls.values()].map((call) => serializeInFlightToolCall(call, now), ), diff --git a/services/atribd/README.md b/services/atribd/README.md index 339d5b1f..e0f7312e 100644 --- a/services/atribd/README.md +++ b/services/atribd/README.md @@ -154,6 +154,62 @@ Caller-facing timeouts do not release the per-context write lock. The lock stays held until the underlying primitive settles. If the late call succeeds, atribd stores its result before the next write in that context begins. +## Request security and cancellation + +HTTP authorization belongs to each request, not to the connection. Embedders +can pass `bearerAuth` to `bindAtribdHttpHost()`. atribd then runs the MCP SDK's +token verifier on every POST before dispatch. The verifier owns token validity, +expiry, required scopes, and revocation checks. + +The optional `rateLimit` hook also runs per POST. It receives the method, tool, +protocol era, action class, and verified principal fields. It does not receive +the bearer token. Neither a reused socket nor +`io.modelcontextprotocol/clientInfo` grants authority. + +Cancellation follows the request signal supplied by the MCP SDK. Reads pass +that signal to the mounted primitive, so a disconnected or timed-out request +can stop its work. Writes use a stricter rule once dispatch starts. The caller +gets a cancellation or timeout error, but the write keeps settling under its +per-context lock. A late success completes the idempotency entry. Health +diagnostics report cancellation and timeout counts separately, including +settlements that arrive after either event. + +atribd does not use MCP `requestState`. A future multi-round tool must verify +and integrity-protect that caller-carried value before it can affect +authorization or routing. See +[D186](../../DECISIONS.md#d186-stateless-mcp-security-and-cancellation-are-request-scoped). + +## Process replacement + +HTTP clients do not repair or recreate a session after atribd restarts. They +retry reads as new requests. A write retry uses the same action-bound +idempotency key and complete arguments. Completed writes replay their original +result. A pending entry stays indeterminate instead of dispatching a second +write. + +The daemon test suite hard-kills a real atribd process on a fixed endpoint, +starts a fresh process, and proves discovery, reads, negotiated receipts, and +completed-write replay. The retry produces one mirror record. The same suite +exercises 32 contexts concurrently, queues competing writes within one context, +and proves another context can progress while that queue is busy. A failure +matrix stops and kills the daemon with discovery, tool listing, reads, and a +negotiated-receipt write pending at the client. Every request recovers by +retrying its complete request. The write uses the same idempotency key and +produces one mirror record. + +The same real-process suite pins broad local-host budgets that catch request +path regressions without treating CI jitter as product latency: first +`server/discover` under 2 seconds; p95 cached `tools/list` and compatibility +routing under 500 milliseconds; p95 reads under 1.5 seconds; and p95 signed +writes with receipt generation under 2 seconds. These budgets cover daemon +work after startup and exclude public-log delivery, which runs through the +degradation queue. + +`tools/list` remains `cacheScope: "private"`. During the alias window its +default `ttlMs` is five minutes and operators can lower it. A client may use a +cached catalogue until that advertised TTL expires, then must refresh before it +assumes a removed or renamed tool still exists. + ## Degradation The [§5.8](../../atrib-spec.md#58-degradation-contract) contract is absolute. Log submission, mirror writes, and health @@ -191,6 +247,29 @@ and mirror stay on the host. binaries or at `atrib-primitives`. Rollback is a config change, not a data migration; no signed byte differs between the topologies. +## Request diagnosis + +Diagnose one bounded request, not a connection: + +1. Record the profile, endpoint, timestamp, MCP method, and tool name. +2. Check `Mcp-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` against the JSON + body. For modern traffic the protocol version is `2026-07-28`. +3. Inspect request `_meta` for protocol version, client info, capabilities, + explicit atrib context, trace context, and the write idempotency key. Redact + bearer tokens and private tool arguments. +4. Inspect the response status, JSON-RPC error or result, attribution receipt, + and health counters. A receipt proves a signed commitment, not the truth of + the underlying claim. +5. Retry reads as fresh requests. Retry writes only with the same idempotency + key and byte-equivalent action. A changed action must use a new key. +6. After process replacement, probe `server/discover`, `tools/list`, and one + non-mutating read. Do not attempt session repair. + +The outer atrib server can be modern while a configured wrapper still talks to +an older upstream MCP server. Diagnose the outer and upstream legs separately. +The outer receipt proves what the atrib layer signed. It does not upgrade the +upstream server's transport or make an upstream claim true. + ## Verify locally ```sh diff --git a/services/atribd/package.json b/services/atribd/package.json index 60731785..78da2601 100644 --- a/services/atribd/package.json +++ b/services/atribd/package.json @@ -41,7 +41,7 @@ "build": "rm -rf dist && tsc && chmod +x dist/index.js", "start": "node dist/index.js", "dev": "tsx src/index.ts", - "test": "vitest run", + "test": "vitest run --no-file-parallelism", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/services/atribd/src/backend.ts b/services/atribd/src/backend.ts index 759c3ee7..aee36590 100644 --- a/services/atribd/src/backend.ts +++ b/services/atribd/src/backend.ts @@ -61,18 +61,23 @@ interface InFlightToolCall { id: string primitive: string tool: string + kind: AtribdHandlerKind startedAt: number timedOutAt?: number + cancelledAt?: number } export interface AtribdToolCallDiagnostic { id: string primitive: string tool: string + kind: AtribdHandlerKind started_at: string elapsed_ms: number timed_out: boolean timed_out_at?: string + cancelled: boolean + cancelled_at?: string } export interface AtribdDiagnostics { @@ -82,11 +87,22 @@ export interface AtribdDiagnostics { calls_succeeded: number calls_failed: number calls_timed_out: number + calls_cancelled: number calls_settled_after_timeout: number + calls_settled_after_cancel: number in_flight_tool_calls: AtribdToolCallDiagnostic[] idempotency?: ReturnType } +export interface AtribdCallOptions { + /** + * Cancellation for this request. Read calls forward it to the primitive. + * A write that already crossed the dispatch boundary keeps settling so its + * idempotency result cannot become a false clean failure. + */ + signal?: AbortSignal +} + export interface AtribdRuntimeContractDiagnostic { status: 'pass' | 'fail' package: string @@ -133,7 +149,7 @@ export interface AtribdBackend { tools: Tool[] toolNames: string[] mountedPrimitiveCount: number - callTool(request: CallToolRequest['params']): Promise + callTool(request: CallToolRequest['params'], options?: AtribdCallOptions): Promise diagnostics(): AtribdDiagnostics runtimeContracts(): AtribdRuntimeContracts flush(): Promise @@ -151,6 +167,7 @@ export interface AtribdBackendOptions { export const DEFAULT_TOOL_TIMEOUT_MS = 45_000 const MCP_REQUEST_TIMEOUT_CODE = -32001 +const MCP_REQUEST_CANCELLED_CODE = -32003 const EXPECTED_RECALL_COVERAGE_VERSION = 'coverage-v1' const EXPECTED_RECALL_CONTENT_INDEX_VERSION = 'content-index-v1' const HEALTH_PROBE_ABSENT_HASH = `sha256:${'f'.repeat(64)}` @@ -269,6 +286,10 @@ function toolTimeoutError(tool: string, timeoutMs: number): McpError { ) } +function toolCancelledError(tool: string): McpError { + return new McpError(MCP_REQUEST_CANCELLED_CODE, `atrib primitive tool ${tool} was cancelled`) +} + export function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } @@ -314,13 +335,18 @@ function serializeInFlightToolCall( id: call.id, primitive: call.primitive, tool: call.tool, + kind: call.kind, started_at: new Date(call.startedAt).toISOString(), elapsed_ms: Math.max(0, now - call.startedAt), timed_out: call.timedOutAt !== undefined, + cancelled: call.cancelledAt !== undefined, } if (call.timedOutAt !== undefined) { serialized.timed_out_at = new Date(call.timedOutAt).toISOString() } + if (call.cancelledAt !== undefined) { + serialized.cancelled_at = new Date(call.cancelledAt).toISOString() + } return serialized } @@ -916,7 +942,9 @@ export async function createAtribdBackend( let callsSucceeded = 0 let callsFailed = 0 let callsTimedOut = 0 + let callsCancelled = 0 let callsSettledAfterTimeout = 0 + let callsSettledAfterCancel = 0 for (const primitive of mounted) { // Route kind is per TOOL, not per mount: any tool name the write @@ -943,6 +971,7 @@ export async function createAtribdBackend( const routeToolCall = ( route: ToolRoute, request: CallToolRequest['params'], + callOptions: AtribdCallOptions = {}, onSuccess?: (result: CallToolResult) => Promise, ): { response: Promise; settled: Promise } => { const id = randomUUID() @@ -951,6 +980,7 @@ export async function createAtribdBackend( id, primitive: route.primitive, tool: request.name, + kind: route.kind, startedAt, } callsStarted += 1 @@ -963,34 +993,67 @@ export async function createAtribdBackend( }) let timeoutHandle: NodeJS.Timeout | undefined - let timedOut = false - const primitiveCall = route.client.callTool(request) as Promise + let removeAbortListener: (() => void) | undefined + let abandoned: 'timeout' | 'cancel' | undefined + const primitiveAbort = route.kind === 'read' ? new AbortController() : undefined + const primitiveCall = route.client.callTool( + request, + primitiveAbort ? { signal: primitiveAbort.signal } : undefined, + ) as Promise const toolCall = primitiveCall.then(async (result) => { await onSuccess?.(result) return result }) - const timeout = new Promise((_, reject) => { + const abandonment = new Promise((_, reject) => { + const cancel = () => { + if (abandoned) return + abandoned = 'cancel' + call.cancelledAt = Date.now() + callsCancelled += 1 + const error = toolCancelledError(request.name) + logDaemonEvent({ + event: 'tool_call_cancelled', + id, + primitive: route.primitive, + tool: request.name, + kind: route.kind, + elapsed_ms: call.cancelledAt - startedAt, + }) + primitiveAbort?.abort(error) + reject(error) + } + if (callOptions.signal?.aborted) { + cancel() + } else if (callOptions.signal) { + callOptions.signal.addEventListener('abort', cancel, { once: true }) + removeAbortListener = () => callOptions.signal?.removeEventListener('abort', cancel) + } timeoutHandle = setTimeout(() => { - timedOut = true + if (abandoned) return + abandoned = 'timeout' call.timedOutAt = Date.now() callsTimedOut += 1 + const error = toolTimeoutError(request.name, toolTimeoutMs) logDaemonEvent({ event: 'tool_call_timed_out', id, primitive: route.primitive, tool: request.name, + kind: route.kind, timeout_ms: toolTimeoutMs, elapsed_ms: call.timedOutAt - startedAt, }) - reject(toolTimeoutError(request.name, toolTimeoutMs)) + primitiveAbort?.abort(error) + reject(error) }, toolTimeoutMs) timeoutHandle.unref?.() }) const response = (async () => { try { - const result = await Promise.race([toolCall, timeout]) + const result = await Promise.race([toolCall, abandonment]) if (timeoutHandle) clearTimeout(timeoutHandle) + removeAbortListener?.() callsSucceeded += 1 inFlightToolCalls.delete(id) logDaemonEvent({ @@ -1003,27 +1066,39 @@ export async function createAtribdBackend( return result } catch (error) { if (timeoutHandle) clearTimeout(timeoutHandle) - if (timedOut) { + removeAbortListener?.() + if (abandoned) { + const abandonedBy = abandoned void toolCall .then( () => { - callsSettledAfterTimeout += 1 + if (abandonedBy === 'timeout') callsSettledAfterTimeout += 1 + else callsSettledAfterCancel += 1 logDaemonEvent({ - event: 'tool_call_settled_after_timeout', + event: + abandonedBy === 'timeout' + ? 'tool_call_settled_after_timeout' + : 'tool_call_settled_after_cancel', id, primitive: route.primitive, tool: request.name, + kind: route.kind, outcome: 'succeeded', elapsed_ms: Date.now() - startedAt, }) }, (lateError: unknown) => { - callsSettledAfterTimeout += 1 + if (abandonedBy === 'timeout') callsSettledAfterTimeout += 1 + else callsSettledAfterCancel += 1 logDaemonEvent({ - event: 'tool_call_settled_after_timeout', + event: + abandonedBy === 'timeout' + ? 'tool_call_settled_after_timeout' + : 'tool_call_settled_after_cancel', id, primitive: route.primitive, tool: request.name, + kind: route.kind, outcome: 'failed', elapsed_ms: Date.now() - startedAt, error: errorMessage(lateError), @@ -1063,6 +1138,7 @@ export async function createAtribdBackend( const callWriteTool = async ( route: ToolRoute, request: CallToolRequest['params'], + callOptions: AtribdCallOptions, ): Promise => { const idempotencyKey = idempotencyKeyFromRequest(request) const key = writeSerializationKey(request) @@ -1073,10 +1149,10 @@ export async function createAtribdBackend( 'atribd: idempotency requires a resolved context_id', ) } - return routeToolCall(route, request).response + return routeToolCall(route, request, callOptions).response } return writeLocks.runUntilSettled(key, async () => { - if (!idempotencyKey) return routeToolCall(route, request) + if (!idempotencyKey) return routeToolCall(route, request, callOptions) const binding = writeActionBinding(request, key) const decision = await idempotencyStore.begin(idempotencyKey, binding) if (decision.kind === 'replay') { @@ -1115,7 +1191,7 @@ export async function createAtribdBackend( ), } } - return routeToolCall(route, request, (result) => + return routeToolCall(route, request, callOptions, (result) => idempotencyStore.complete(decision.keyHash, decision.binding, result), ) }) @@ -1124,13 +1200,14 @@ export async function createAtribdBackend( const callReadTool = ( route: ToolRoute, request: CallToolRequest['params'], - ): Promise => routeToolCall(route, request).response + callOptions: AtribdCallOptions, + ): Promise => routeToolCall(route, request, callOptions).response return { tools, toolNames: tools.map((tool) => tool.name), mountedPrimitiveCount: mounted.length, - callTool: async (request) => { + callTool: async (request, callOptions = {}) => { const route = routeByTool.get(request.name) if (!route) { throw new McpError( @@ -1138,7 +1215,9 @@ export async function createAtribdBackend( `unknown atrib primitive tool: ${request.name}`, ) } - return route.kind === 'write' ? callWriteTool(route, request) : callReadTool(route, request) + return route.kind === 'write' + ? callWriteTool(route, request, callOptions) + : callReadTool(route, request, callOptions) }, diagnostics: () => { const now = Date.now() @@ -1149,7 +1228,9 @@ export async function createAtribdBackend( calls_succeeded: callsSucceeded, calls_failed: callsFailed, calls_timed_out: callsTimedOut, + calls_cancelled: callsCancelled, calls_settled_after_timeout: callsSettledAfterTimeout, + calls_settled_after_cancel: callsSettledAfterCancel, in_flight_tool_calls: [...inFlightToolCalls.values()].map((call) => serializeInFlightToolCall(call, now), ), diff --git a/services/atribd/src/http-host.ts b/services/atribd/src/http-host.ts index f6c1456b..faced01e 100644 --- a/services/atribd/src/http-host.ts +++ b/services/atribd/src/http-host.ts @@ -39,8 +39,15 @@ import { CallToolRequestSchema, ListToolsRequestSchema, isInitializeRequest, + type CallToolRequest, } from '@modelcontextprotocol/sdk/types.js' -import { Server as ModernServer } from '@modelcontextprotocol/server' +import { + Server as ModernServer, + bearerAuthChallengeResponse, + verifyBearerToken, + type AuthInfo, + type BearerAuthOptions, +} from '@modelcontextprotocol/server' import { ATTRIBUTION_EXTENSION_ID, ATTRIBUTION_EXTENSION_VERSION, @@ -54,6 +61,7 @@ import { readPackageVersion, runtimeContractsDegraded, toolCallDiagnosticsDegraded, + WRITE_TOOL_NAMES, DEFAULT_TOOL_TIMEOUT_MS, type AtribdBackend, } from './backend.js' @@ -108,11 +116,37 @@ export interface AtribdRequestCounters { legacy_requests: number rejected_header_mismatch: number rejected_missing_context: number + rejected_auth: number + rate_limited: number legacy_initialize: number legacy_session_header_ignored: number method_not_allowed: number } +export interface AtribdRequestPrincipal { + client_id: string + scopes: string[] + expires_at?: number + resource?: string + attributes?: Record +} + +export interface AtribdRateLimitContext { + method: string + tool?: string + action_class: 'write' | 'read' | 'control' | 'unknown' + protocol_era: 'modern' | 'legacy' + principal?: AtribdRequestPrincipal +} + +export type AtribdRateLimitDecision = + | { allowed: true } + | { allowed: false; retry_after_ms?: number; reason?: string } + +export type AtribdRateLimit = ( + context: AtribdRateLimitContext, +) => AtribdRateLimitDecision | Promise + export interface AtribdHttpHost { endpoint: string healthEndpoint: string @@ -135,6 +169,17 @@ export interface AtribdHttpHostOptions { * is the default. Working flag name; final name is a P046 open question. */ ambientContext?: boolean + /** + * Optional MCP-native bearer verification. The verifier runs on every MCP + * POST and must reject revoked or otherwise invalid tokens. Health remains + * unauthenticated so supervisors can diagnose a failed auth dependency. + */ + bearerAuth?: BearerAuthOptions + /** + * Optional per-request limiter. It receives verified identity facts without + * the bearer token and is called independently of HTTP connection reuse. + */ + rateLimit?: AtribdRateLimit compatibilityObserver?: McpCompatibilityObserver compatibilityStateFile?: string | false expectedModernClient?: boolean @@ -208,6 +253,56 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void { res.end(bytes) } +async function sendWebResponse(res: ServerResponse, response: Response): Promise { + res.statusCode = response.status + for (const [name, value] of response.headers) { + res.setHeader(name, value) + } + const bytes = new Uint8Array(await response.arrayBuffer()) + res.setHeader('content-length', bytes.byteLength) + res.end(bytes) +} + +function rateLimitContext( + body: unknown, + authInfo: AuthInfo | undefined, + protocolVersion: string | undefined, +): AtribdRateLimitContext { + const record = body && typeof body === 'object' && !Array.isArray(body) + ? (body as Record) + : {} + const method = typeof record.method === 'string' ? record.method : 'unknown' + const params = + record.params && typeof record.params === 'object' && !Array.isArray(record.params) + ? (record.params as Record) + : undefined + const tool = method === 'tools/call' && typeof params?.name === 'string' ? params.name : undefined + const actionClass: AtribdRateLimitContext['action_class'] = + method !== 'tools/call' + ? 'control' + : tool === undefined + ? 'unknown' + : WRITE_TOOL_NAMES.has(tool) + ? 'write' + : 'read' + const principal: AtribdRequestPrincipal | undefined = authInfo + ? { + client_id: authInfo.clientId, + scopes: [...authInfo.scopes], + ...(authInfo.expiresAt !== undefined ? { expires_at: authInfo.expiresAt } : {}), + ...(authInfo.resource !== undefined ? { resource: String(authInfo.resource) } : {}), + ...(authInfo.extra !== undefined ? { attributes: authInfo.extra } : {}), + } + : undefined + return { + method, + ...(tool !== undefined ? { tool } : {}), + action_class: actionClass, + protocol_era: protocolVersion === '2026-07-28' ? 'modern' : 'legacy', + ...(principal !== undefined ? { principal } : {}), + } +} + function sendJsonRpcError( res: ServerResponse, status: number, @@ -390,11 +485,11 @@ export function createAtribdServer(options: AtribdServerFactoryOptions): Server } }) - server.setRequestHandler(CallToolRequestSchema, async (request) => { + server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { const backend = await options.getBackend() const policy = options.httpContextPolicy if (!policy) { - return backend.callTool(request.params) + return backend.callTool(request.params, { signal: extra.signal }) } const outcome = applyHttpContextPolicy(request.params, { ambientContext: policy.ambientContext, @@ -410,7 +505,7 @@ export function createAtribdServer(options: AtribdServerFactoryOptions): Server if (outcome.kind === 'injected') { policy.onInjected?.() } - return backend.callTool(outcome.params) + return backend.callTool(outcome.params, { signal: extra.signal }) }) return server @@ -467,33 +562,42 @@ export function createAtribdModernServer(options: AtribdServerFactoryOptions): M } }) - server.setRequestHandler('tools/call', async (request) => { + server.setRequestHandler('tools/call', async (request, context) => { const backend = await options.getBackend() const policy = options.httpContextPolicy + // The v2 SDK places request metadata beside `params`, while the v1 SDK + // carried it inside `params`. Normalize before context and idempotency + // policy so both protocol eras bind the same complete request. + const requestMeta: unknown = + options.requestMeta ?? + (request as { _meta?: unknown })._meta ?? + (request.params as { _meta?: unknown })._meta + const params: CallToolRequest['params'] = + requestMeta === undefined + ? request.params + : ({ ...request.params, _meta: requestMeta } as CallToolRequest['params']) const result = !policy - ? await backend.callTool(request.params) + ? await backend.callTool(params, { signal: context.mcpReq.signal }) : await (async () => { - const outcome = applyHttpContextPolicy(request.params, { + const outcome = applyHttpContextPolicy(params, { ambientContext: policy.ambientContext, }) if (outcome.kind === 'rejected') { policy.onRejected?.() logDaemonEvent({ event: 'write_call_rejected_missing_context', - tool: request.params.name, + tool: params.name, }) return outcome.result } if (outcome.kind === 'injected') policy.onInjected?.() - return backend.callTool(outcome.params) + return backend.callTool(outcome.params, { signal: context.mcpReq.signal }) })() const internalRecord = signedRecordFromToolResult(result) if (internalRecord) { applyAttributionReceipt( result as Record, - options.requestMeta ?? - (request as { _meta?: unknown })._meta ?? - (request.params as { _meta?: unknown })._meta, + requestMeta, internalRecord, ) delete (result as { _meta?: Record })._meta?.[INTERNAL_RECORD_META_KEY] @@ -549,6 +653,8 @@ export async function bindAtribdHttpHost( legacy_requests: 0, rejected_header_mismatch: 0, rejected_missing_context: 0, + rejected_auth: 0, + rate_limited: 0, legacy_initialize: 0, legacy_session_header_ignored: 0, method_not_allowed: 0, @@ -667,6 +773,8 @@ export async function bindAtribdHttpHost( local_substrate_endpoint: process.env.ATRIB_LOCAL_SUBSTRATE_ENDPOINT, context_id_policy: ambientContext ? 'ambient-opt-in' : 'explicit-required', requires_explicit_context_id: !ambientContext, + authentication: options.bearerAuth ? 'oauth-bearer-required' : 'loopback-unauthed', + rate_limit: options.rateLimit ? 'request-scoped' : 'not-configured', }, requests: { ...counters }, compatibility: compatibility.report(), @@ -724,6 +832,52 @@ export async function bindAtribdHttpHost( counters.legacy_initialize += 1 } + let authInfo: AuthInfo | undefined + if (options.bearerAuth) { + try { + authInfo = await verifyBearerToken( + headerValue(req, 'authorization'), + options.bearerAuth, + ) + } catch (error) { + counters.rejected_auth += 1 + await sendWebResponse( + res, + bearerAuthChallengeResponse(error, { + ...(options.bearerAuth.requiredScopes !== undefined + ? { requiredScopes: options.bearerAuth.requiredScopes } + : {}), + ...(options.bearerAuth.resourceMetadataUrl !== undefined + ? { resourceMetadataUrl: options.bearerAuth.resourceMetadataUrl } + : {}), + }), + ) + return + } + } + + if (options.rateLimit) { + const rateLimit = await options.rateLimit( + rateLimitContext(body, authInfo, headerValue(req, 'mcp-protocol-version')), + ) + if (!rateLimit.allowed) { + counters.rate_limited += 1 + if ( + rateLimit.retry_after_ms !== undefined && + Number.isFinite(rateLimit.retry_after_ms) && + rateLimit.retry_after_ms >= 0 + ) { + res.setHeader('retry-after', String(Math.ceil(rateLimit.retry_after_ms / 1000))) + } + sendJsonRpcError(res, 429, -32029, rateLimit.reason ?? 'Too Many Requests') + return + } + } + + if (authInfo) { + ;(req as IncomingMessage & { auth?: AuthInfo }).auth = authInfo + } + counters.served += 1 if (headerValue(req, 'mcp-protocol-version') === '2026-07-28') { counters.modern_requests += 1 diff --git a/services/atribd/src/index.ts b/services/atribd/src/index.ts index 6c8935e0..4ddcbbf4 100644 --- a/services/atribd/src/index.ts +++ b/services/atribd/src/index.ts @@ -68,6 +68,7 @@ export { DEFAULT_TOOL_TIMEOUT_MS, type AtribdBackend, type AtribdBackendOptions, + type AtribdCallOptions, type AtribdPrimitiveFactory, type AtribdPrimitiveHandle, type AtribdHandlerKind, @@ -105,6 +106,10 @@ export { type AtribdHttpHost, type AtribdHttpHostOptions, type AtribdRequestCounters, + type AtribdRequestPrincipal, + type AtribdRateLimit, + type AtribdRateLimitContext, + type AtribdRateLimitDecision, type AtribdServerFactoryOptions, } from './http-host.js' export { diff --git a/services/atribd/test/atribd.test.ts b/services/atribd/test/atribd.test.ts index ad7b0947..5d1cc03f 100644 --- a/services/atribd/test/atribd.test.ts +++ b/services/atribd/test/atribd.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:net' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { z } from 'zod' @@ -84,6 +85,57 @@ function delay(ms: number): Promise { return new Promise((resolveDelay) => setTimeout(resolveDelay, ms)) } +function percentile(samples: number[], fraction: number): number { + const sorted = [...samples].sort((left, right) => left - right) + return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))] ?? Infinity +} + +async function timedRequest(run: () => Promise): Promise { + const startedAt = performance.now() + const response = await run() + expect(response.status).toBe(200) + await response.arrayBuffer() + return performance.now() - startedAt +} + +async function freeTcpPort(): Promise { + const server = createServer() + await new Promise((resolveListen, rejectListen) => { + server.once('error', rejectListen) + server.listen(0, '127.0.0.1', resolveListen) + }) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('failed to reserve a TCP port') + await new Promise((resolveClose, rejectClose) => { + server.close((error) => (error ? rejectClose(error) : resolveClose())) + }) + return address.port +} + +async function waitForExit(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) return + await new Promise((resolveExit) => child.once('exit', () => resolveExit())) +} + +async function retryPost( + endpoint: string, + body: unknown, + headers: Record, + deadlineMs = 30_000, +): Promise { + const deadline = Date.now() + deadlineMs + let lastError: unknown + while (Date.now() < deadline) { + try { + return await postJson(endpoint, body, headers) + } catch (error) { + lastError = error + await delay(25) + } + } + throw lastError instanceof Error ? lastError : new Error('request retry deadline expired') +} + function base64url(bytes: Uint8Array): string { return Buffer.from(bytes).toString('base64url') } @@ -118,7 +170,9 @@ function emptyDiagnostics(toolTimeoutMs = 45_000): AtribdDiagnostics { calls_succeeded: 0, calls_failed: 0, calls_timed_out: 0, + calls_cancelled: 0, calls_settled_after_timeout: 0, + calls_settled_after_cancel: 0, in_flight_tool_calls: [], idempotency: { schema: 'atrib.mcp-write-idempotency.v1', @@ -563,6 +617,100 @@ describe('atribd stateless HTTP host', () => { } }) + it('verifies bearer auth per request without exposing the token to rate limiting', async () => { + const { backend } = await fakeToolBackend() + const seen: unknown[] = [] + const host = await bindAtribdHttpHost({ + port: 0, + backendFactory: async () => backend, + bearerAuth: { + requiredScopes: ['atrib:read'], + verifier: { + async verifyAccessToken(token) { + expect(token).toBe('valid-secret') + return { + token, + clientId: 'principal-from-token', + scopes: ['atrib:read'], + expiresAt: Math.floor(Date.now() / 1000) + 60, + extra: { tenant_id: 'tenant-a' }, + } + }, + }, + }, + rateLimit: (context) => { + seen.push(context) + return { allowed: true } + }, + }) + try { + const missing = await postJson(host.endpoint, TOOLS_LIST_BODY) + expect(missing.status).toBe(401) + expect(host.requestCounters().rejected_auth).toBe(1) + + const accepted = await postJson(host.endpoint, modernRequest(31, 'tools/list'), { + ...MODERN_HEADERS, + 'mcp-method': 'tools/list', + authorization: 'Bearer valid-secret', + }) + expect(accepted.status, await accepted.clone().text()).toBe(200) + expect(seen).toEqual([ + { + method: 'tools/list', + action_class: 'control', + protocol_era: 'modern', + principal: { + client_id: 'principal-from-token', + scopes: ['atrib:read'], + expires_at: expect.any(Number), + attributes: { tenant_id: 'tenant-a' }, + }, + }, + ]) + expect(JSON.stringify(seen)).not.toContain('valid-secret') + } finally { + await host.close() + } + }) + + it('rate limits every request independently of connection or client name', async () => { + const { backend, calls } = await fakeToolBackend() + let requests = 0 + const host = await bindAtribdHttpHost({ + port: 0, + backendFactory: async () => backend, + rateLimit: (context) => { + expect(context.principal).toBeUndefined() + expect(context.action_class).toBe('read') + requests += 1 + return requests === 1 + ? { allowed: true } + : { allowed: false, retry_after_ms: 1_500, reason: 'request budget exhausted' } + }, + }) + try { + const first = await postJson( + host.endpoint, + toolsCallBody(32, 'fake_read', {}, { + 'io.modelcontextprotocol/clientInfo': { name: 'trusted-looking-name' }, + }), + ) + const second = await postJson( + host.endpoint, + toolsCallBody(33, 'fake_read', {}, { + 'io.modelcontextprotocol/clientInfo': { name: 'different-name' }, + }), + ) + expect(first.status).toBe(200) + expect(second.status).toBe(429) + expect(second.headers.get('retry-after')).toBe('2') + expect(calls.filter((call) => call.tool === 'fake_read')).toHaveLength(1) + expect(host.requestCounters().rate_limited).toBe(1) + } finally { + await host.close() + } + }) + it('returns an equivalent read result when the same request lands on a different instance', async () => { const first = await bindAtribdHttpHost({ port: 0, @@ -807,17 +955,112 @@ describe('atribd write serialization', () => { it('lets writes on different contexts overlap', async () => { const { backend, maxConcurrentWrites } = await fakeToolBackend({ writeDelayMs: 30 }) - await Promise.all([ + const contexts = Array.from({ length: 32 }, (_, index) => + index.toString(16).padStart(32, '0'), + ) + await Promise.all( + contexts.map((contextId) => + backend.callTool({ + name: 'emit', + arguments: { context_id: contextId, content: {} }, + }), + ), + ) + expect(maxConcurrentWrites()).toBe(contexts.length) + await backend.close() + }) + + it('keeps independent contexts moving behind one busy context', async () => { + const { backend, calls } = await fakeToolBackend({ writeDelayMs: 25 }) + const busy = Array.from({ length: 8 }, (_, index) => backend.callTool({ name: 'emit', - arguments: { context_id: CONTEXT_A, content: {} }, + arguments: { context_id: CONTEXT_A, content: { index } }, }), - backend.callTool({ + ) + const independent = backend.callTool({ name: 'emit', arguments: { context_id: CONTEXT_B, content: {} }, - }), - ]) - expect(maxConcurrentWrites()).toBe(2) + }) + await independent + expect(calls.some((call) => call.args['context_id'] === CONTEXT_B)).toBe(true) + expect(calls.filter((call) => call.args['context_id'] === CONTEXT_A).length).toBeLessThan(8) + await Promise.all(busy) + await backend.close() + }) +}) + +describe('atribd request cancellation', () => { + it('forwards cancellation to a read primitive and frees the route', async () => { + let primitiveObservedAbort = false + const backend = await createAtribdBackend({ + primitives: [ + [ + 'reader', + () => { + const mcp = new McpServer({ name: 'abort-aware-reader', version: '0.0.0' }) + mcp.registerTool( + 'abortable_read', + { description: 'Abort-aware read', inputSchema: {} }, + async (_args, extra) => { + await new Promise((_resolve, reject) => { + extra.signal.addEventListener( + 'abort', + () => { + primitiveObservedAbort = true + reject(extra.signal.reason) + }, + { once: true }, + ) + }) + }, + ) + return { mcp } + }, + ], + ], + }) + const controller = new AbortController() + const call = backend.callTool( + { name: 'abortable_read', arguments: {} }, + { signal: controller.signal }, + ) + await delay(5) + controller.abort() + await expect(call).rejects.toThrow(/abortable_read was cancelled/) + await delay(10) + expect(primitiveObservedAbort).toBe(true) + expect(backend.diagnostics()).toMatchObject({ + active_tool_calls: 0, + calls_cancelled: 1, + calls_settled_after_cancel: 1, + }) + await backend.close() + }) + + it('keeps a cancelled write settling and replays its eventual result', async () => { + const { backend, calls } = await fakeToolBackend({ writeDelayMs: 35 }) + const controller = new AbortController() + const request = { + name: 'emit', + arguments: { context_id: CONTEXT_A, content: { what: 'settle after cancel' } }, + _meta: { 'dev.atrib/idempotencyKey': 'cancelled-write-0001' }, + } + const call = backend.callTool(request, { signal: controller.signal }) + await delay(5) + controller.abort() + await expect(call).rejects.toThrow(/emit was cancelled/) + await delay(50) + + const replay = await backend.callTool(request) + expect(replay.content).toEqual([{ type: 'text', text: JSON.stringify({ ok: true }) }]) + expect(calls).toHaveLength(1) + expect(backend.diagnostics()).toMatchObject({ + active_tool_calls: 0, + calls_cancelled: 1, + calls_settled_after_cancel: 1, + idempotency: { pending: 0, completed: 1 }, + }) await backend.close() }) }) @@ -928,11 +1171,8 @@ describe('atribd health surface', () => { } }) - it('times out hung primitive calls and exposes in-flight diagnostics', async () => { - let releaseTool!: () => void - const toolGate = new Promise((resolveTool) => { - releaseTool = resolveTool - }) + it('cancels timed-out read primitives and releases the routed request', async () => { + let primitiveObservedAbort = false const backend = await createAtribdBackend({ toolTimeoutMs: 25, primitives: [ @@ -946,9 +1186,17 @@ describe('atribd health surface', () => { description: 'Slow test tool', inputSchema: {}, }, - async () => { - await toolGate - return { content: [{ type: 'text', text: 'released' }] } + async (_args, extra) => { + await new Promise((_resolve, reject) => { + extra.signal.addEventListener( + 'abort', + () => { + primitiveObservedAbort = true + reject(extra.signal.reason) + }, + { once: true }, + ) + }) }, ) return { mcp } @@ -967,15 +1215,17 @@ describe('atribd health surface', () => { await expect(client.callTool({ name: 'slow_tool', arguments: {} })).rejects.toThrow( /slow_tool timed out after 25ms/, ) - const degraded = (await (await fetch(host.healthEndpoint)).json()) as { + await delay(10) + const health = (await (await fetch(host.healthEndpoint)).json()) as { status?: string report?: { tool_calls?: AtribdDiagnostics } } - expect(degraded.status).toBe('degraded') - expect(degraded.report?.tool_calls?.calls_timed_out).toBe(1) - expect(degraded.report?.tool_calls?.in_flight_tool_calls[0]?.tool).toBe('slow_tool') + expect(primitiveObservedAbort).toBe(true) + expect(health.status).toBe('degraded') + expect(health.report?.tool_calls?.calls_timed_out).toBe(1) + expect(health.report?.tool_calls?.calls_settled_after_timeout).toBe(1) + expect(health.report?.tool_calls?.active_tool_calls).toBe(0) } finally { - releaseTool() await client?.close().catch(() => {}) await host.close() } @@ -1081,6 +1331,7 @@ describe('atribd real primitive mounts', () => { ATRIB_AGENT: 'test-agent', ATRIB_PRIVATE_KEY: TEST_PRIVATE_KEY, ATRIB_RECORD_FILE: recordFile, + ATRIB_MIRROR_FILE: recordFile, }) try { const requestMeta = { @@ -1173,6 +1424,349 @@ describe('atribd real primitive mounts', () => { }) }) +describe('atribd process replacement', () => { + it( + 'recovers discovery, reads, writes, receipts, and retries without session repair', + { timeout: 90_000 }, + async () => { + const port = await freeTcpPort() + const contextId = '9'.repeat(32) + const idempotencyKey = 'process-replacement-write-0001' + const env = { + HOME: tmp, + NODE_ENV: 'production', + ATRIB_AGENT: 'resilience-test', + ATRIBD_IDEMPOTENCY_STATE_FILE: join(tmp, 'idempotency.json'), + ATRIB_PRIVATE_KEY: TEST_PRIVATE_KEY, + ATRIB_RECORD_FILE: recordFile, + ATRIB_MIRROR_FILE: recordFile, + ATRIB_LOG_ENDPOINT: 'http://127.0.0.1:9/v1/entries', + } + const args = ['--port', String(port), '--tools-list-ttl-ms', '100'] + const headers = { + ...MODERN_HEADERS, + 'mcp-method': 'tools/call', + 'mcp-name': 'emit', + } + const requestMeta = { + ...modernMeta(), + 'dev.atrib/idempotencyKey': idempotencyKey, + 'io.modelcontextprotocol/clientCapabilities': { + extensions: { + 'dev.atrib/attribution': { version: '0.1', accept: ['record'] }, + }, + }, + } + const write = toolsCallBody( + 501, + 'emit', + { + context_id: contextId, + event_type: 'observation', + content: { what: 'survives process replacement' }, + }, + requestMeta, + ) + + const first = await startHttpHostProcess(env, args) + let second: HttpHostProcess | undefined + try { + const discover = await postJson( + first.endpoint, + modernRequest(499, 'server/discover'), + { ...MODERN_HEADERS, 'mcp-method': 'server/discover' }, + ) + expect(discover.status).toBe(200) + + const read = await postJson( + first.endpoint, + toolsCallBody(500, 'recall_my_attribution_history', { compact: true }, modernMeta()), + { + ...MODERN_HEADERS, + 'mcp-method': 'tools/call', + 'mcp-name': 'recall_my_attribution_history', + }, + ) + expect(read.status).toBe(200) + + const initialWrite = await postJson(first.endpoint, write, headers) + expect(initialWrite.status).toBe(200) + const initialPayload = (await initialWrite.json()) as { + result?: { + _meta?: { + 'dev.atrib/attribution'?: { receipt?: { record_hash?: string } } + } + } + } + expect( + initialPayload.result?._meta?.['dev.atrib/attribution']?.receipt?.record_hash, + ).toMatch(/^sha256:[0-9a-f]{64}$/) + + first.child.kill('SIGKILL') + await waitForExit(first.child) + + const retry = retryPost(first.endpoint, write, headers) + second = await startHttpHostProcess(env, args) + const retriedWrite = await retry + expect(retriedWrite.status).toBe(200) + const retriedPayload = (await retriedWrite.json()) as { result?: unknown } + expect(retriedPayload.result).toEqual(initialPayload.result) + + const list = await postJson( + second.endpoint, + modernRequest(502, 'tools/list'), + { ...MODERN_HEADERS, 'mcp-method': 'tools/list' }, + ) + const listed = (await list.json()) as { + result?: { ttlMs?: number; cacheScope?: string; tools?: unknown[] } + } + expect(listed.result?.ttlMs).toBe(100) + expect(listed.result?.cacheScope).toBe('private') + expect(listed.result?.tools?.length).toBe(EXPECTED_TOOL_NAMES.length) + + const records = readFileSync(recordFile, 'utf8') + .split('\n') + .filter(Boolean) + expect(records).toHaveLength(1) + } finally { + if (second) await second.close() + else await first.close() + } + }, + ) + + it( + 'retries requests that lose their connection while the daemon is stopped', + { timeout: 120_000 }, + async () => { + const port = await freeTcpPort() + const contextId = '8'.repeat(32) + const env = { + HOME: tmp, + NODE_ENV: 'production', + ATRIB_AGENT: 'in-flight-kill-test', + ATRIBD_IDEMPOTENCY_STATE_FILE: join(tmp, 'idempotency-in-flight.json'), + ATRIB_PRIVATE_KEY: TEST_PRIVATE_KEY, + ATRIB_RECORD_FILE: recordFile, + ATRIB_MIRROR_FILE: recordFile, + ATRIB_LOG_ENDPOINT: 'http://127.0.0.1:9/v1/entries', + } + const args = ['--port', String(port), '--tools-list-ttl-ms', '100'] + const cases = [ + { + name: 'discovery', + body: modernRequest(601, 'server/discover'), + headers: { ...MODERN_HEADERS, 'mcp-method': 'server/discover' }, + }, + { + name: 'tool listing', + body: modernRequest(602, 'tools/list'), + headers: { ...MODERN_HEADERS, 'mcp-method': 'tools/list' }, + }, + { + name: 'read', + body: toolsCallBody( + 603, + 'recall_my_attribution_history', + { compact: true }, + modernMeta(), + ), + headers: { + ...MODERN_HEADERS, + 'mcp-method': 'tools/call', + 'mcp-name': 'recall_my_attribution_history', + }, + }, + { + name: 'write with negotiated receipt', + body: toolsCallBody( + 604, + 'emit', + { + context_id: contextId, + event_type: 'observation', + content: { what: 'survives an in-flight connection loss' }, + }, + { + ...modernMeta(), + 'dev.atrib/idempotencyKey': 'in-flight-kill-write-0001', + 'io.modelcontextprotocol/clientCapabilities': { + extensions: { + 'dev.atrib/attribution': { version: '0.1', accept: ['record'] }, + }, + }, + }, + ), + headers: { + ...MODERN_HEADERS, + 'mcp-method': 'tools/call', + 'mcp-name': 'emit', + }, + }, + ] + + for (const requestCase of cases) { + const first = await startHttpHostProcess(env, args) + let settled = false + const pending = postJson(first.endpoint, requestCase.body, requestCase.headers).finally( + () => { + settled = true + }, + ) + void pending.catch(() => {}) + first.child.kill('SIGSTOP') + await delay(20) + + expect(settled, `${requestCase.name} settled while the daemon was stopped`).toBe(false) + + first.child.kill('SIGKILL') + await waitForExit(first.child) + await pending.catch(() => undefined) + + const second = await startHttpHostProcess(env, args) + try { + const response = await retryPost( + second.endpoint, + requestCase.body, + requestCase.headers, + ) + expect(response.status, requestCase.name).toBe(200) + const payload = (await response.json()) as { + result?: { + _meta?: { + 'dev.atrib/attribution'?: { receipt?: { record_hash?: string } } + } + } + } + if (requestCase.name === 'write with negotiated receipt') { + expect( + payload.result?._meta?.['dev.atrib/attribution']?.receipt?.record_hash, + ).toMatch(/^sha256:[0-9a-f]{64}$/) + } + } finally { + await second.close() + } + } + + const records = readFileSync(recordFile, 'utf8') + .split('\n') + .filter(Boolean) + expect(records).toHaveLength(1) + }, + ) + + it( + 'keeps stable request paths within local daemon latency budgets', + { timeout: 90_000 }, + async () => { + const port = await freeTcpPort() + const contextId = '7'.repeat(32) + const env = { + HOME: tmp, + NODE_ENV: 'production', + ATRIB_AGENT: 'request-budget-test', + ATRIBD_IDEMPOTENCY_STATE_FILE: join(tmp, 'idempotency-benchmark.json'), + ATRIB_PRIVATE_KEY: TEST_PRIVATE_KEY, + ATRIB_RECORD_FILE: recordFile, + ATRIB_MIRROR_FILE: recordFile, + ATRIB_LOG_ENDPOINT: 'http://127.0.0.1:9/v1/entries', + } + const host = await startHttpHostProcess(env, [ + '--port', + String(port), + '--tools-list-ttl-ms', + '100', + ]) + try { + const coldDiscoveryMs = await timedRequest(() => + postJson( + host.endpoint, + modernRequest(700, 'server/discover'), + { ...MODERN_HEADERS, 'mcp-method': 'server/discover' }, + ), + ) + const cachedListMs: number[] = [] + const readMs: number[] = [] + const writeReceiptMs: number[] = [] + const compatibilityMs: number[] = [] + + for (let index = 0; index < 8; index += 1) { + cachedListMs.push( + await timedRequest(() => + postJson( + host.endpoint, + modernRequest(710 + index, 'tools/list'), + { ...MODERN_HEADERS, 'mcp-method': 'tools/list' }, + ), + ), + ) + readMs.push( + await timedRequest(() => + postJson( + host.endpoint, + toolsCallBody( + 720 + index, + 'recall_my_attribution_history', + { compact: true }, + modernMeta(), + ), + { + ...MODERN_HEADERS, + 'mcp-method': 'tools/call', + 'mcp-name': 'recall_my_attribution_history', + }, + ), + ), + ) + writeReceiptMs.push( + await timedRequest(() => + postJson( + host.endpoint, + toolsCallBody( + 730 + index, + 'emit', + { + context_id: contextId, + event_type: 'observation', + content: { what: `request budget sample ${index}` }, + }, + { + ...modernMeta(), + 'dev.atrib/idempotencyKey': `request-budget-write-${index}`, + 'io.modelcontextprotocol/clientCapabilities': { + extensions: { + 'dev.atrib/attribution': { version: '0.1', accept: ['record'] }, + }, + }, + }, + ), + { + ...MODERN_HEADERS, + 'mcp-method': 'tools/call', + 'mcp-name': 'emit', + }, + ), + ), + ) + compatibilityMs.push( + await timedRequest(() => + postJson(host.endpoint, TOOLS_LIST_BODY, { 'mcp-method': 'tools/list' }), + ), + ) + } + + expect(coldDiscoveryMs).toBeLessThan(2_000) + expect(percentile(cachedListMs, 0.95)).toBeLessThan(500) + expect(percentile(readMs, 0.95)).toBeLessThan(1_500) + expect(percentile(writeReceiptMs, 0.95)).toBeLessThan(2_000) + expect(percentile(compatibilityMs, 0.95)).toBeLessThan(500) + } finally { + await host.close() + } + }, + ) +}) + describe('atribd CLI options', () => { it('accepts the deprecated --session-idle-ms as an ignored no-op', () => { const options = parseCliOptions([