Skip to content

Middleware chain

Every tool call goes through a four-layer middleware chain before reaching the tool’s run method. The order is fixed and matters: each layer assumes the ones outside it have already run.

graph LR
A[tools/call] --> B[telemetry]
B --> C[validate]
C --> D[preconditions]
D --> E[audit]
E --> F[Tool.run]
F --> E
E --> D
D --> C
C --> B
B --> A

Read it as a Koa pipeline: each middleware wraps next(), runs setup, awaits the inner chain, runs teardown, returns. The arrows show the call descending to the handler then unwinding back out.

The composition primitive is in packages/mcp-core/src/middleware/compose.ts: the same Koa-style dispatcher (with the standard “next() called twice” guard).

Source: middleware/telemetry.ts

Wraps the entire call in an OTel SERVER span and emits the three tool-level metrics (mcp.tool.duration_ms, mcp.tool.calls, mcp.tool.errors). Always fires — even for calls that fail validation or preconditions, because we want to see those failures in dashboards.

Why outermost: a call that gets rejected by validation should still be counted (so you can spot a buggy agent that’s spamming bad inputs). If validation ran first and rejected before telemetry, you’d lose visibility on exactly the calls that need monitoring.

Source: middleware/validate.ts

Runs the tool’s zod schema against arguments. On failure, throws ValidationError with a structured issues array (each issue has path, message, code).

Why second: if args are invalid, preconditions can’t safely inspect them (e.g. ConfirmRequired reads args.__confirm — if args is the wrong shape, that read might throw before the validation message ever surfaces). Validating first means preconditions and the handler both work with a typed, well-formed payload.

Source: middleware/precondition.ts

Runs every Precondition piece declared by the tool. The two built-in preconditions are:

Preconditions throw structured errors; the handler never runs if any precondition fails.

Why before audit: a precondition that rejects (e.g. “wrong scope”) shouldn’t audit the tool body — the tool didn’t actually execute.

Source: middleware/audit.ts

Captures the redacted args, the result (success / tool_error / thrown), the duration, and the OTel trace/span IDs (if active). Emits once per call to the configured sink. Skips calls where tool.idempotent === true — read patterns are already in telemetry.

Why innermost: audit is meaningful only for actually-attempted operations. A call rejected by validation or preconditions never reaches the handler; auditing it would log non-events and balloon the trail.

The mental model is outermost = most universal, innermost = most specific:

LayerSeesSkips on
Telemetryevery callnothing — always observes
Validateevery call passed by telemetrymalformed args
Preconditionsevery call passed by validatescope/dry-run/confirm violations
Auditevery call passed by preconditionsidempotent: true tools

Each inner layer assumes the outer guarantees: audit knows args are well-formed; preconditions know args parse; validate knows the call exists.

Reversing the order breaks each guarantee in a subtle way — e.g. moving audit outside preconditions means logging “would have audited” events that never executed, which is worse than silence for compliance review.

Each layer receives a MiddlewareContext:

interface MiddlewareContext<Args = unknown> {
readonly tool: { name: string; category: string; idempotent: boolean };
readonly args: Args;
readonly meta: Map<string, unknown>;
}

meta is the bag for cross-layer state (e.g. telemetry stashes the active span; audit reads it to attach trace_id/span_id). Layers communicate via this map rather than monkey-patching the context — keeps the type contract clean.

If you ever need a fifth layer (e.g. rate limiting per-tenant), insert it between preconditions and audit: it should observe valid + permitted calls, but its rejection should still count as a “tool not executed” for audit purposes. Don’t add it after audit — that’s the contract boundary.