Skip to content

Latest commit

 

History

History
243 lines (189 loc) · 12 KB

06_middleware.md

File metadata and controls

243 lines (189 loc) · 12 KB

Make a request pass through policy and observation

Role: implementation. Assumes interactive calls. Baseline FastMCP 4.1.1. This lesson traces one rejection through nested middleware, then extends the model to bounded quotas, metrics, tracing and composition.

Ticket reads, draft tools and publication tools all need timing and policy checks. Copying those checks into each handler makes ordering inconsistent: one tool records a denial, another returns before recording it, and a third forgets the check. Middleware is a wrapper around a request path. A wrapper can act before forwarding, await the next layer, then act as the result or exception returns.

Run the complete local middleware example:

/tmp/mcp-python-repair-venv/bin/python examples/python/middleware.py

It verifies rejection unwinds outer timer; resource read unchanged; window 60@0, reject@59, allow@60. The retired handler never executes; a resource read still succeeds. The limiter is a deterministic, single-principal local model, not a multi-tenant quota service.

The first registered layer enters first and exits last

The fixture registers Timing() before RetiredTool(). Timing.on_call_tool records entry and uses try/finally around await call_next(ctx). RetiredTool.on_call_tool rejects the name retired with a fixed ToolError and otherwise forwards the call. The test records exactly:

timing enter
policy enter
timing exit

There is no handler entry because policy raised before reaching it. The timer still exits because the exception unwinds through its finally. See both request and failure paths in the diagram:

sequenceDiagram
    participant C as Client
    participant T as Timing outer wrapper
    participant P as RetiredTool policy
    participant H as retired handler
    C->>T: tools/call retired
    T->>T: record start
    T->>P: await call_next
    P-->>T: raise ToolError retired
    Note over H: never entered
    T->>T: finally records duration
    T-->>C: tool error result after framework conversion
Loading

Moving the timer inside policy changes the measured population: rejected requests never enter it. That is a design choice, not a performance bug. Use an outer timer when you need admission latency and denials; an inner timer can measure only admitted handler work. Document which population a dashboard means.

This is the central wrapper body, an explanatory excerpt from the complete fixture:

async def on_call_tool(self, ctx, call_next):
    trace.append("timing enter")
    start = time.monotonic()
    try:
        return await call_next(ctx)
    finally:
        trace.append("timing exit")

call_next is async. Omitting await returns a coroutine rather than its result, so expected execution and exception handling do not happen at this location. Calling it twice can run the operation twice. Skipping it means the request stops at this layer; then the wrapper must either raise an appropriate error or return a correctly typed operation result. Returning None from a tool call is not a generic safe denial.

Choose the hook that matches the intended scope

MiddlewareContext is the dispatcher envelope: it includes the method and decoded message. Its optional fastmcp_context is the request's FastMCP Context, which can reach lifespan resources and request state. These objects answer different questions: “what operation is being intercepted?” versus “what runtime data belongs to it?”

Hook Why choose it
on_message Observe requests and notifications at the dispatcher boundary
on_request Apply work common to requests that expect a response
on_call_tool Inspect tool name/arguments without changing resource reads
on_list_tools Filter the discovered tool catalog
on_read_resource Enforce a resource-specific read rule
on_get_prompt Apply prompt retrieval policy

The example's policy is tool-specific, so it does not intercept docs://readme. Conversely, hiding a tool in on_list_tools does not prevent a caller from invoking its known name directly. Enforce permission again on invocation. The production discovery and authorization lesson develops that two-path policy.

Modern clients do not run initialize, so on_initialize is not a place to enforce access for current requests. HTTP authentication belongs at the supported resource server boundary with appropriate bearer challenges and token validation; MCP middleware can then apply operation policy to verified identity. A custom JWT parser inside a tool wrapper does not automatically implement OAuth discovery or HTTP authentication semantics. Middleware dispatch (checked 2026-10-19).

Trace the exact rate-limit algorithm

The fixture's Window keeps a deque of admitted timestamps. At time now it removes entries whose age is at least 60 seconds. If 60 entries remain, it rejects; otherwise it appends now and admits. This is a sliding window, not a token bucket.

Time Existing admitted timestamps after eviction Decision
0, calls 1–60 0 through 59 entries at time 0 All 60 admitted
59, call 61 60 entries, each 59 seconds old Reject; no new timestamp
60, next call All time-0 entries evicted Admit; deque now has one entry

A token bucket would refill tokens over time and could admit a call before time 60. The fixture uses >= 60, making the exact boundary explicit. A monotonic clock avoids wall-clock adjustments changing elapsed-time calculations.

The single deque is bounded by the admitted limit. To add per-principal quotas, derive the key from verified identity, cap the number of retained keys, expire inactive buckets and handle eviction without granting an attacker unlimited fresh buckets. A protocol session id is not a stable user identity, especially in modern mode. A shared anonymous key protects an overall budget but can let one anonymous caller consume everyone else's allowance.

Multiple processes holding separate deques multiply the available allowance. To enforce a fleet-wide quota use a shared atomic decision: for a Redis sliding window, one script must evict, count, test and append without another worker interleaving. A plain INCR plus EXPIRE instead implements a fixed-window counter unless a more complete design changes its semantics. Decide whether quota counts attempts, admitted work or successful work; charging successful calls alone allows expensive failures to escape the budget. Scaling owns fleet placement.

Metrics must distinguish transport, tool and asking outcomes

A counter with bounded tool and outcome labels can separate complete, input_required, tool_error and exception. A histogram records elapsed seconds for the same chosen population. Define those outcomes against the hook result:

  • A raised ToolError in this wrapper is a known domain failure.
  • An unexpected raised exception is a separate failure, with sanitized details in server logs.
  • A returned tool result may itself mark is_error; inspect returned status where the API permits it.
  • A guard's returned input-required result is a normal asking round, not a completed business operation.

A simple except ToolError counter is not a complete count of every invalid request: validation can fail at another dispatcher layer, authorization has its own exception types, and a protocol error can occur before the operation hook. Observe outer request errors as well, without double counting one event as several calls. The local fixture proves one explicit policy-denial path, not a complete Prometheus exporter installation. Production observability owns the executed metrics/export pipeline.

Use bounded labels from the registered catalog, not arbitrary argument values, request ids, user ids or resource URIs; otherwise every input creates a new time series. A spike in tool errors can mean bad arguments, an upstream outage, permission changes or a client regression. Diagnose by stable error category and correlated trace before assigning blame to the caller.

Trace requests without retaining secrets

A tracing wrapper starts a span, records bounded operation metadata, and re-raises exceptions after recording failure. FastMCP already has native OpenTelemetry instrumentation; add custom spans only for meaningful application work or explicit coverage gaps to avoid duplicate indistinguishable spans. Use the active trace context for correlation rather than logging access tokens or complete arguments.

Make both successful and rejected requests observable. Ensure cancellation still unwinds timers and resource cleanup. If a guard requests input, each round gets its own request span and the logical workflow needs a safe correlation id across them; the old suspended-request span is not still alive while a person decides.

Composition creates nested policy boundaries

Parent middleware runs for requests routed through the parent, while child middleware runs for operations handled by that child. A parent can therefore impose a common budget before the child-specific policy. In a live mount, child inventory changes can become visible later, so policy cannot assume the catalog was frozen when mounted.

Request-local state can carry a verified user object within that one operation. Per-instance serializable stores do not automatically become one shared store when servers are mounted. Configure shared application state explicitly where intended, and avoid inferring cross-call identity from a value left by a previous request. The next lesson verifies live mounts and separately traces an HTTP API bridge.

Operationally, alert on sustained denial rate changes, queue/admission delay and limiter/backend failures. Choose an explicit failure policy for the quota backend: fail closed for critical writes, or permit a bounded degraded read allowance when that trade-off is intended. Keep the policy version in deployment metadata so a rollback can restore both behavior and the observable cohort.

Recap

  • An outer wrapper observes inner success and failure while unwinding in reverse order.
  • Hooks determine which operations a policy can affect; discovery filtering alone cannot enforce invocation.
  • A sliding window evicts old timestamps and admits under a bound; fleet enforcement needs an atomic shared decision.
  • Metrics count defined outcomes and populations, including normal input-required rounds.

Check yourself

Logging is registered before authorization and authorization rejects. Can logging still record the denial?

Answer

Yes, if its error/finally path observes the exception. It entered first and is still on the call stack when the inner layer raises. If registered inside authorization, it may never enter for denied calls.

A limiter admitted 60 calls at time 0. Does the fixture admit one at time 59?

Answer

No. All 60 timestamps are still less than 60 seconds old. At time 60 the >=60 eviction removes them. Predicting continuous refill would be a token-bucket model, which this deque does not implement.

Try it

Extend the local policy to reject retired while recording one bounded outcome for that tool. Keep a resource read unaffected. Hint: use on_call_tool, preserve await call_next exactly once, and assert both denial and the absence of a handler entry rather than merely checking a returned string.

Worked approach

Record policy-denied before raising for the known retired name. Forward other names once. Put duration recording in the outer timer's finally. Call the retired tool with raise_on_error=False, assert is_error, and verify the resource text remains context. A resource rejection would show the hook scope changed accidentally.

Next: Compose clients and recover uncertain writes.