Skip to main content

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:

  1. 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.

  2. A contractless interceptor pipeline in the base driver (rejected). The first middleware proposal put _interceptors + a _dispatch chain 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."

  3. Object decorators (the outgoing design). BaseDecorator wraps a single inner MCSToolDriver; concerns ship as HooksDecorator, PermissionDecorator, AuthDecorator. This respected the paradigm ("everything is a driver") -- but practice exposed real handling costs:

    • Identity loss. PermissionDecorator(RestDriver(...)) is only an MCSToolDriver: 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 needed DriverMeta.resolve_capability and the SupportsCapabilityResolution traversal 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.

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.

  • ToolMiddleware port (core). One around-hook: on_execute_tool(tool_name, arguments, call_next) -- observe, rewrite, short-circuit (permission denial returns a payload instead of calling call_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 own execute_tool. (A future on_list_tools(call_next) hook for tool filtering is a foreseen extension; default pass-through.)

  • SupportsToolMiddleware contract (core). middleware=[...] at construction plus add_middleware(mw) afterwards. BaseDriver implements it exemplarily, like any other Supports* contract, threading _invoke_tool through 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-hooksHooksMiddleware, mcs-permissionPermissionMiddleware, mcs-authAuthMiddleware. 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's meta.capabilities: DriverMeta is 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_middleware appends (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 single inner) 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, SupportsCapabilityResolution and BaseDecorator are 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.
  • Auth stays per-driver. AuthMiddleware is 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: isinstance for 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 BaseDriver and does not implement the contract cannot receive middleware (formerly BaseDecorator's niche). Accepted: an orchestrator (itself a BaseDriver) can host the middleware above such a leaf.
  • Migration: add the ToolMiddleware port + SupportsToolMiddleware contract to core and implement in BaseDriver; convert HooksDecorator / PermissionDecorator / AuthDecorator to middleware; delete BaseDecorator, SupportsCapabilityResolution and DriverMeta.resolve_capability; switch examples from resolve_capability to isinstance; replace the decorator_stack example with a middleware example; migrate tests.