Skip to content

Middleware chain

Every tool call goes through a six-stage 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[default guild]
C --> D[validate]
D --> E[category gate]
E --> F[preconditions]
F --> G[audit]
G --> H[Tool.run]
H --> G
G --> F
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/default-guild.ts

When DISCORD_DEFAULT_GUILD_ID is set, supplies it only if the call omitted a top-level guild_id and the selected tool declares that field. Explicit input always wins; tools without guild_id are unchanged.

Why before validation: the default must be present before a required guild_id schema is parsed.

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 before policy gates: if args are invalid, later stages cannot 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/category.ts

Enforces the MCP_CATEGORIES allowlist for every registered tool. The meta category stays available so mcp_pipeline can be called, but each pipeline step re-enters the dispatcher and is checked against its own category.

Why this is middleware: a new tool cannot accidentally bypass the allowlist by forgetting to declare a precondition.

Source: middleware/precondition.ts

Runs every Precondition piece declared by the tool. The store contains two built-in pieces, but only identifiers in that tool’s metadata run:

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

Why before audit: a precondition that rejects (for example, missing confirmation) 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:

StageSeesStops or skips on
Telemetryevery callnothing - always observes
Default guildraw call plus selected tool schemano matching field/default
Validatedefaulted argumentsmalformed args
Category gatevalidated callsdisallowed category
Preconditionsvalid, category-allowed callsconfirmation or custom policy rejection
Auditcalls that passed every earlier stageskips idempotent: true tools

Each inner stage assumes the outer guarantees: audit receives a valid, category-allowed call; preconditions receive parsed args; validation receives any configured default guild.

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.

Place a new policy stage before audit if rejection means the tool never executed. Its exact position depends on whether it needs raw, defaulted, or validated arguments; cover that ordering decision with a dispatcher-level test.