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.pyIt 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 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
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.
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).
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.
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
ToolErrorin 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.
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.
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.
- 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.
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.
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.