Skip to main content

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:

  1. 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.
  2. 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.
  3. 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 healthcheck that 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 provides healthcheck too, 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 typeMechanism
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 driversOrchestrator (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.