ADR-0002 · Cross-cutting concerns: a middleware contract, not object decorators
Status: Accepted Date: 2026-07
Context
Cross-cutting concerns -- lifecycle hooks, permission/consent, in-band auth
challenge handling -- need to intercept a driver's execute_tool. The mechanism for
this has been decided twice before, and both decisions taught us something:
-
Mixins (rejected). Behaviour woven in by multiple inheritance. Rejected because the order is fixed by the MRO (not configurable) and multiple implementation inheritance does not exist in most target languages -- a standard must not rest on a language-specific mechanism.
-
A contractless interceptor pipeline in the base driver (rejected). The first middleware proposal put
_interceptors+ a_dispatchchain into the base driver as private SDK machinery. Rejected on paradigm grounds -- the objection, verbatim:"Welchen Contract erfüllt DriverBase? DriverBase soll ja ein Treiber sein, der nur verschiedene Contracts beispielhaft erfüllt. Sobald er mehr macht als MCS vorschreibt, verletzt er das Paradigma -- dann könnten wir genauso gut Hooks fest einbauen."
A pipeline that exists in no MCS contract is the same violation as hard-wired hooks, just with an extra indirection: "not more modular, only more cumbersome."
-
Object decorators (the outgoing design).
BaseDecoratorwraps a single innerMCSToolDriver; concerns ship asHooksDecorator,PermissionDecorator,AuthDecorator. This respected the paradigm ("everything is a driver") -- but practice exposed real handling costs:- Identity loss.
PermissionDecorator(RestDriver(...))is only anMCSToolDriver:process_llm_response, system-message generation and streaming are gone. Decorators therefore only work for orchestrator-composed tool drivers, never for the standalone driver a client actually talks to. - The driver builds its own tool driver internally (RestDriver → RestToolDriver); wrapping requires reaching an object that is constructed inside.
- A hand-built onion --
Hooks(Permission(Auth(...)))-- per driver, per environment. - The emergency construct. Because the wrapper hides the inner driver's
interfaces, clients could not use
isinstance; every capability lookup neededDriverMeta.resolve_capabilityand theSupportsCapabilityResolutiontraversal machinery -- complexity that exists only to see through decorators. - One decorator instance per wrapped driver. A concern configured once (one consent handler for the whole client) still needs N wrapper objects.
- Identity loss.
Decision
Tool middleware becomes a first-class MCS contract. The mechanism the earlier rejection lacked -- a contract -- is exactly what fixes it: the pipeline is no longer private machinery beside the standard, it is standard.
-
ToolMiddlewareport (core). One around-hook:on_execute_tool(tool_name, arguments, call_next)-- observe, rewrite, short-circuit (permission denial returns a payload instead of callingcall_next), or catch domain errors (auth challenge → in-band result).call_next(tool_name, arguments)invokes the rest of the chain; the terminal is the driver's ownexecute_tool. (A futureon_list_tools(call_next)hook for tool filtering is a foreseen extension; default pass-through.) -
SupportsToolMiddlewarecontract (core).middleware=[...]at construction plusadd_middleware(mw)afterwards.BaseDriverimplements it exemplarily, like any otherSupports*contract, threading_invoke_toolthrough the chain -- inside the existing try/except, so error shaping (ToolCallRecord.error, self-heal) is unchanged. This answers the old objection instead of bypassing it: the base driver again does only what contracts prescribe. -
Concerns ship as middleware in their own packages.
mcs-hooks→HooksMiddleware,mcs-permission→PermissionMiddleware,mcs-auth→AuthMiddleware. Their contracts (SupportsHooks,SupportsConsent,SupportsAuth) stay in those packages (see the capability-contract placement rule); the middleware objects implement them. Those flags are not folded onto the driver'smeta.capabilities:DriverMetais a static data sheet of the driver class (built for finding drivers in a repository, reproducible as JSON/XML without instantiating anything), while the middleware an instance runs is configuration the client applied at runtime. The client that built a middleware holds its reference and needs no lookup. Should a static-to-runtime bridge ever be needed, it will be designed when the driver repository is. -
Ordering = list order, outermost first. The first middleware sees the call first and the result last.
add_middlewareappends (innermost, closest to execution). Order is configuration, visible in one line -- never inheritance or nesting. -
Middleware is shared configuration. One instance may serve all drivers in a client (it holds config/handlers, never per-call state) -- one
PermissionMiddleware(consent=ask_user)for the whole driver list, something a decorator (bound to its singleinner) could never do. -
Detection collapses to
isinstance. Two directions, two mechanisms:- Driver → client ("what can this driver do": streaming, native tools,
healthcheck): the driver object is never hidden behind a wrapper anymore, so
isinstance(driver, SupportsStreaming)works directly.DriverMeta.resolve_capability,SupportsCapabilityResolutionandBaseDecoratorare removed -- the emergency construct existed only for decorator traversal. - Client → driver ("behave like this": hooks, permission): no detection at all -- the client constructed the middleware, so it already holds the object it would otherwise have to find.
- Driver → client ("what can this driver do": streaming, native tools,
healthcheck): the driver object is never hidden behind a wrapper anymore, so
-
Auth stays per-driver.
AuthMiddlewareis added only to drivers where an in-band challenge flow makes sense; transport credentials remain an adapter concern. Production stacks may add hooks+permission to every driver -- that is now one shared list, not N wrappers.
Alternatives considered
- Keep object decorators -- paradigm-clean ("everything is a driver") but the handling costs above are structural, not fixable by sugar: the wrapper must hide the driver to be a driver. Rejected.
- Contractless pipeline in the base driver -- rejected before, and that rejection stands: machinery without a contract breaks the "exemplary implementation" paradigm. This ADR adopts the mechanism only because it gets a contract.
- Typed per-concern constructor parameters (
BaseDriver(hooks=…, permission=…)) -- core would depend on the concern packages; dependency direction violated. Rejected.
Consequences
- Clients work with the real driver object again:
isinstancefor capabilities, full driver identity (streaming, prompts) regardless of active concerns. - One honest trade-off: middleware is not a driver. MCS now has two specified composition axes -- drivers compose via orchestrators; concerns compose via the middleware chain. Concerns were never really drivers; wrapping them as drivers is what caused the identity loss.
- A driver that does not extend
BaseDriverand does not implement the contract cannot receive middleware (formerlyBaseDecorator's niche). Accepted: an orchestrator (itself aBaseDriver) can host the middleware above such a leaf. - Migration: add the
ToolMiddlewareport +SupportsToolMiddlewarecontract to core and implement inBaseDriver; convertHooksDecorator/PermissionDecorator/AuthDecoratorto middleware; deleteBaseDecorator,SupportsCapabilityResolutionandDriverMeta.resolve_capability; switch examples fromresolve_capabilitytoisinstance; replace thedecorator_stackexample with a middleware example; migrate tests.