Skip to content

Carry the client's trace context (traceparent) into the audit record #413

Description

@plusky

Blocks #414.

Depends on #411 — there is no record to put the trace context on until that lands.

A client driving mtui-mcp typically has its own tracing — an agent framework emitting OpenTelemetry spans, a CI job with a run id. Nothing carries that context into the server, so correlating "the caller decided to install the update" with "mtui-mcp ran install against host X at 03:14" is manual, by timestamp, across two systems whose clocks and log formats are unrelated.

Where the context already arrives

MCP carries trace context in the request's params._meta (SEP-414), and call_tool already reads that map — crates/mtui-mcp/src/server.rs:262-270 pulls the progress token out of exactly the same place:

let sink: Option<PeerProgressSink> =
    context
        .meta
        .get_progress_token()
        .map(|token| PeerProgressSink {
            peer: context.peer.clone(),
            token,
        });

So this is not new plumbing: it is one more read from context.meta at a site that already reads it, next to the point where the audit record is built.

Proposal

Parse traceparent (and tracestate if present) from params._meta, validate the W3C shape, and store the trace id / span id on the record. Absent or malformed context is simply an absent field — never an error, never a refusal.

Three properties that matter more than the parsing:

  • Untrusted input. The value is whatever the client sent. It is fine as a correlation key and must never be trusted as an identity, an authorisation, or anything that reaches a shell.
  • No behavioural coupling. Presence or absence of trace context must not change dispatch, output, or the bytes of any response. Its only effect is on what the record contains.
  • Bounded. A traceparent is fixed-width; anything that is not the expected shape is dropped rather than stored, so a client cannot use the field to write arbitrary bytes into the audit file.

Why bother

Without it, an audit trail answers "what did the server do" and the caller's own traces answer "what did the caller intend", and joining the two is a human reading two clocks. With it, the join is a field — which is what makes shipping the records somewhere central worth doing at all.

Suggested acceptance

  • A call carrying a well-formed traceparent produces a record with the trace id and span id; the same call without one produces a record identical but for those fields.
  • A malformed or oversized traceparent is dropped, the call still succeeds, and nothing unvalidated reaches the file.
  • Responses are byte-identical with and without the header, which is the property that keeps this from being observable to the client.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions