Skip to content

feat(audit): populate trace context from params._meta traceparent - #41

Merged
plusky merged 1 commit into
mainfrom
audit-traceparent-28
Aug 2, 2026
Merged

feat(audit): populate trace context from params._meta traceparent#41
plusky merged 1 commit into
mainfrom
audit-traceparent-28

Conversation

@plusky

@plusky plusky commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Closes #28.

The audit schema's trace field (trace_id + span_id) existed since 0.2.0 with nothing populating it. This wires it up: a traceparent sent in a tool call's params._meta (SEP-414) is validated and its ids copied into the call's record — at both record sites, the pre-dispatch fail-closed gate included. Record enrichment only (I15): the value never influences the guard, the refusal decision, or the response, and responses are asserted byte-identical with and without it, over the duplex transport and over real streamable http.

What ships

  • TraceContext::from_traceparent (audit.rs) — strict by design: exactly the W3C version-00 layout, 55 bytes checked first on bytes (oversized or multibyte-unicode input is rejected before anything slices), lowercase hex only, non-zero trace and span ids, flags validated but not stored. ff and every future version are refused — no lenient forward parsing; ids from a format revision the parser cannot fully validate are not stored. Malformed records nothing rather than something wrong, and the client-controlled value is never logged, valid or not.
  • call_tool wiring (server.rs) — one extraction before dispatch, used by both ToolCallEvent sites.
  • Docs — DESIGN.md audit-stream bullet (including that stored ids are unauthenticated client claims — correlation hints, never evidence; attribution rests on session/client anchoring), an rmcp usage note on the _meta delivery path (below), extended testing bars; a README sentence on the --audit-config row.
  • Deliberate skips, documented: tracestate (schema has no field; client free text) and baggage (unbounded client key/values) — revisit under Adopt the 2026-07-28 MCP revision — audit schema v1 assumes three things it removes #34. No schema change, no new dependencies, no config knob: enrichment is unconditional while auditing is on.

The rmcp 2.2 _meta delivery path (the round's load-bearing fact)

Over every serialized transport the wire params._meta does not arrive in CallToolRequestParams.meta — rmcp's custom Deserialize for Request strips _meta out of the params into the request's extensions typemap, and the serve loop hands it to the handler as RequestContext.meta. The pre-implementation spec asserted the opposite direction; the implementation agent refuted it empirically (the spec's wiring failed every trace test) and shipped the correct read: params-struct meta first (in-process callers that never serialize), falling back to context.meta, where every real transport delivers the value. Both arms are pinned by tests that fail if either read is dropped.

Adversarial review record

Three review lenses (security-bypass, correctness/fail-closed, docs-vs-code) plus an independent mutation-verification pass ran against the implementation commit; every finding is addressed in the final commit, none rebutted:

  1. security/minor — DESIGN.md framed client control of traceparent purely as log hygiene, not stating the stored ids are forgeable. Fixed: the doc now says any client can stamp any call with any well-formed ids (its own probes with an innocent service's ids, or unrelated calls with an id under investigation); correlation hints, never evidence.
  2. correctness/important + docs/important (×2, independent) — the http test file's module-doc coverage contract and inline comment documented the _meta trap backwards (claiming the test proves a request.meta read and naming an unkillable mutant), contradicting DESIGN.md and the commit body in the same commit. Fixed: both comments now state the true direction and name the mutant the file actually kills.
  3. correctness/minor — the params-struct arm was dead in every test and every serialized transport, so no coverage claim about it could hold. Fixed by pinning it: a new in-process test invokes ServerHandler::call_tool directly with the traceparent in CallToolRequestParams.meta and a hand-built RequestContext asserted to carry no meta — the only caller shape that populates that field.
  4. mutation/blocking — M4 survived — a context.meta-only read was behavior-preserving over every transport the suite exercised. Fixed by the same in-process test; the kill was re-proven by re-applying the M4 mutant in a throwaway detached worktree (the new test fails on it, passes on the unmutated tree).

Mutation verification: 8/8 killed (in a detached worktree at the implementation commit; M4's kill added by the fix and re-proven):

Mutation Killed by
M1 swap trace-id/span-id ranges 5 tests (parser + 3 integration)
M2 drop all-zero trace-id rejection traceparent_rejects_all_zero_ids
M3 accept uppercase hex parser + malformed_traceparent_leaves_the_record_without_trace
M4 read only context.meta in_process_params_meta_traceparent_enriches_the_record (added post-review)
M4-inverse read only params-struct meta 3 transport tests incl. the streamable-http end-to-end
M5 gate-site event gets trace: None failing_sink_gate_record_carries_the_trace
M6 length == 55 → truncating >= 55 traceparent_rejects_wrong_lengths + whitespace test
M7 version check accepts any hex traceparent_rejects_versions_other_than_00
M8 drop all-zero span-id rejection traceparent_rejects_all_zero_ids

Gate re-run independently on the final commit: cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace --all-targets --locked (329 tests), cargo deny check, typos — all green.

The audit schema's trace field (trace_id + span_id) is now populated
from the traceparent value a client sends in a tool call's params._meta
(SEP-414), at both call_tool record sites: the pre-dispatch fail-closed
gate record and the one-record-per-call event. Record enrichment only
(I15): the value never influences the guard, the refusal decision, or
the response, and there is no configuration knob — enrichment is
unconditional while auditing is on. The stored ids are unauthenticated
client claims — correlation hints for an operator, never evidence of
origin (documented in DESIGN.md). No schema change, no new
dependencies.

The parser (TraceContext::from_traceparent) is strict by design:
exactly the W3C version-00 layout — 55 bytes,
00-<32 lowercase hex>-<16 lowercase hex>-<2 lowercase hex> — with
non-zero trace and span ids. The length check runs first, on bytes, so
oversized or multibyte-unicode input is rejected before anything else
looks at the value; version ff and all future versions are refused (no
lenient forward parsing: ids from a format revision the parser cannot
fully validate are not stored); uppercase hex is invalid per W3C; the
flags are validated but not stored. Anything malformed records nothing
rather than something wrong, and the client-controlled input value is
never logged, valid or not.

The rmcp 2.2 request-meta trap: over every serialized transport the
wire params._meta never arrives in CallToolRequestParams.meta — the
SDK's custom Request deserializer strips _meta out of the params into
the request extensions, and the serve loop exposes it to the handler as
RequestContext.meta. call_tool therefore consults the params-struct
meta first (in-process callers that never serialize) and falls back to
context.meta, where every real transport delivers the value. Both arms
are pinned: the context.meta fallback by the duplex and streamable-http
integration tests (a params-struct-only read fails them), and the
params-struct arm by a direct in-process ServerHandler::call_tool test
with a hand-built RequestContext — the only caller shape that populates
that field, so a context.meta-only read is behavior-preserving over
every serialized transport and only the in-process test can kill it.

tracestate and baggage are deliberately not stored: the schema has no
field for either, tracestate is client free text, and baggage is
unbounded client key/values — revisit under #34.
@plusky
plusky merged commit 3a6723f into main Aug 2, 2026
10 checks passed
@plusky
plusky deleted the audit-traceparent-28 branch August 2, 2026 17:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Populate audit trace context from params._meta (traceparent)

1 participant