feat(audit): populate trace context from params._meta traceparent - #41
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #28.
The audit schema's
tracefield (trace_id+span_id) existed since 0.2.0 with nothing populating it. This wires it up: atraceparentsent in a tool call'sparams._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.ffand 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_toolwiring (server.rs) — one extraction before dispatch, used by bothToolCallEventsites._metadelivery path (below), extended testing bars; a README sentence on the--audit-configrow.tracestate(schema has no field; client free text) andbaggage(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
_metadelivery path (the round's load-bearing fact)Over every serialized transport the wire
params._metadoes not arrive inCallToolRequestParams.meta— rmcp's customDeserialize for Requeststrips_metaout of the params into the request's extensions typemap, and the serve loop hands it to the handler asRequestContext.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 tocontext.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:
traceparentpurely 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._metatrap backwards (claiming the test proves arequest.metaread 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.ServerHandler::call_tooldirectly with the traceparent inCallToolRequestParams.metaand a hand-builtRequestContextasserted to carry no meta — the only caller shape that populates that field.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):
traceparent_rejects_all_zero_idsmalformed_traceparent_leaves_the_record_without_tracecontext.metain_process_params_meta_traceparent_enriches_the_record(added post-review)trace: Nonefailing_sink_gate_record_carries_the_trace== 55→ truncating>= 55traceparent_rejects_wrong_lengths+ whitespace testtraceparent_rejects_versions_other_than_00traceparent_rejects_all_zero_idsGate 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.