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.
The chain
Section titled “The chain”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 --> ARead 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).
Layer 1: telemetry (outermost)
Section titled “Layer 1: telemetry (outermost)”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.
Layer 2: default guild
Section titled “Layer 2: default guild”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.
Layer 3: validate
Section titled “Layer 3: validate”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.
Layer 4: category gate
Section titled “Layer 4: category gate”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.
Layer 5: preconditions
Section titled “Layer 5: preconditions”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:
ConfirmRequired(preconditions/ConfirmRequired.ts)- gates destructive tools behind
__confirm:true+MCP_DRY_RUN=false.
- gates destructive tools behind
CategoryEnabled(preconditions/CategoryEnabled.ts)- remains available for embedders; the built-in server applies the category gate globally in layer 4.
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.
Layer 6: audit (innermost)
Section titled “Layer 6: audit (innermost)”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.
Why this order
Section titled “Why this order”The mental model is outermost = most universal, innermost = most specific:
| Stage | Sees | Stops or skips on |
|---|---|---|
| Telemetry | every call | nothing - always observes |
| Default guild | raw call plus selected tool schema | no matching field/default |
| Validate | defaulted arguments | malformed args |
| Category gate | validated calls | disallowed category |
| Preconditions | valid, category-allowed calls | confirmation or custom policy rejection |
| Audit | calls that passed every earlier stage | skips 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.
Per-call context
Section titled “Per-call context”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.
Adding a stage
Section titled “Adding a stage”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.
Related
Section titled “Related”- Operations → Telemetry - the metrics emitted by layer 1.
- Operations → Audit - the AuditEvent schema emitted by layer 6.
- Architecture → Confirmation - the precondition behavior in layer 5.
- Architecture → Error handling - how layer-thrown errors surface to the client.