6 · Middleware
The Orchestrator showed the first kind of composition:
many drivers aggregated behind a single MCSDriver interface. Middleware
is the second kind — and the two are deliberately different in nature.
A driver exposes optional behaviour in two ways. Most features simply add a new method — a health check, a native-tool context, … — and leave everything else untouched. But some cross-cutting concerns — authentication, permission/approval, lifecycle hooks — do not add an isolated method; they need to intervene in an existing operation, namely the tool-call execution. MCS expresses that through middleware.
What middleware is
Middleware is an interceptor around execute_tool that lives inside the
driver. It is not a driver. It receives the pending call plus a call_next
continuation, and may observe the call, rewrite its arguments, short-circuit it,
or catch a domain error and turn it into an in-band result:
interface ToolMiddleware:
on_execute_tool(name, args, call_next) -> any
class AuthMiddleware implements ToolMiddleware:
on_execute_tool(name, args, call_next):
try -> call_next(name, args)
catch AuthChallenge -> describe the auth step as a tool result
The driver holds an ordered list of them and threads every tool call through it. Order is list order, outermost first: the first entry sees the call first and the result last.
driver = RestDriver(spec, middleware=[Hooks(...), Permission(...), Auth()])
A driver that accepts middleware declares the SupportsToolMiddleware contract
(a constructor parameter plus add_middleware). Middleware carries
configuration, not per-call state, so a single instance can be shared across
every driver in a stack.
Why middleware (and not mix-ins)
Implementation inheritance (mix-ins that override execute_tool) can achieve
the same effect in a single language, but it has three problems that matter
for a cross-language standard:
- Portability. Multiple inheritance of implementation does not exist in many OO languages (Java, C#, Go, …). Middleware needs neither: it is composition plus a callback — a one-method interface and a list. That composes everywhere; inherited behaviour does not.
- Order. With inheritance, the order of layers is fixed by the class declaration and reads as arbitrary. With middleware the order is the list order, chosen by the client at composition time.
- Combinatorial explosion. Mix-ins force one driver class per capability
combination (
Rest+Auth,Rest+Auth+Permission, …). Middleware is a single composable object that applies to any driver.
Why middleware (and not decorators)
Earlier revisions of this specification expressed these concerns as decorators — a driver wrapping another driver. That solves the three problems above just as well, but it charges a price it does not have to. ADR-0002 records the change; the essence:
- A decorator must be a driver, so the onion looks like a driver from the outside. It therefore has to re-implement and delegate the entire interface, and every layer must be revisited whenever that interface grows.
- A wrapper hides the inner layers.
isinstance(driver, SupportsHealthcheck)only ever sees the outermost object, so the standard needed an additional resolution mechanism to walk the stack inward — machinery that existed purely to repair what the wrapping had broken. - Composing concerns meant hand-building the onion at every call site, and the resulting object was harder to configure than to construct.
Middleware lives inside the driver, so the driver keeps its identity:
isinstance tells the truth again, the resolution machinery is gone, and the
concerns are a constructor parameter instead of a nesting expression.
The trade-off is deliberate and worth stating plainly: middleware is not a
driver. It cannot be injected wherever an MCSDriver is expected. It does not
need to be — it configures how a driver executes, it is not a participant in
the driver graph.
Where middleware sits
Middleware intercepts execute_tool — the ToolDriver layer. It does not
wrap process_llm_response: the surrounding driver keeps its single
response-processing loop and routes each individual tool call through the chain
beneath it.
A concern implemented as middleware still has a contract (SupportsAuth,
SupportsConsent, SupportsHooks, …) that declares it and carries its
CAPABILITY flag — the middleware is just the example implementation, exactly
as BaseDriver is the example implementation of the core contracts. A driver
author may also build the behaviour directly into their own driver instead of
using middleware.
Because middleware runs inside the driver, it never surfaces on the driver's
type — and it is deliberately not mirrored in the driver's
DriverMeta
either. Which concerns an instance runs is configuration the client applied at
runtime, not a property of the driver, and DriverMeta is a static description
of the driver class — the two must not be mixed. The client that built a
middleware holds its own reference to it and needs no lookup.
Two axes of composition
MCS composes along two axes, and they must not be confused:
- Drivers compose into a driver — that is the
Orchestrator, and it is opaque. It bundles many
drivers into a new entity with its own identity. A capability held by one of
its N drivers is not a capability of the whole (which of the N — and what
would "healthy" even mean across the bundle?). So an orchestrator advertises
only the capabilities it provides itself. To offer an aggregate capability
it implements it — e.g. a
healthcheckthat iterates its first-level drivers and combines their results the way that stack requires. It does not pass its drivers' capabilities through. (Nesting still cascades: a registered driver that is itself an orchestrator provideshealthchecktoo, so the call recurses into its own first level via plain delegation.) - Concerns compose into a driver — that is the middleware chain, and it is internal. It changes how one driver executes a call; it creates no new entity and hides nothing.
Either way the outside sees just an MCSDriver — and because nothing wraps it
any more, isinstance is the honest answer about what it can do.
Reference implementation
The Python SDK defines ToolMiddleware and SupportsToolMiddleware in
mcs-driver-core, and BaseDriver implements the contract: it holds the
ordered list and threads every tool call through it. The concrete concerns ship
in their own packages — mcs-hooks, mcs-permission, mcs-auth — each a
single class implementing on_execute_tool alongside its Supports… contract.
The mechanism lives in the kernel for the same reason BaseDriver does: it is
pure composition mechanism — an ordered list and a continuation —
zero-dependency, with no concept of its own. The orchestrator is also
composition, but it carries an abstraction of its own (a pluggable resolution
strategy), so it ships as a separate package (mcs-orchestrator-base). The rule
of thumb: pure mechanism stays in the kernel; composition that carries its own
strategy becomes its own package.
Summary
| Concern type | Mechanism |
|---|---|
Adds a new method (healthcheck, get_native_tool_context) | Contract (interface) implemented by the driver, detected via isinstance, advertised via its CAPABILITY flag |
| Intervenes in tool execution (auth, permission, hooks) | Middleware (ToolMiddleware) passed to the driver; runtime configuration, so it stays out of the driver's DriverMeta |
| Combines many drivers | Orchestrator (a driver), opaque — advertises only what it implements itself |
In all three cases the core contract stays minimal, and whatever the client holds is still just a driver.