Skip to content

feat(mcp): add tool-call middleware seam#378

Draft
gbrlcustodio wants to merge 10 commits into
devfrom
feat/374-tool-call-middleware
Draft

feat(mcp): add tool-call middleware seam#378
gbrlcustodio wants to merge 10 commits into
devfrom
feat/374-tool-call-middleware

Conversation

@gbrlcustodio

@gbrlcustodio gbrlcustodio commented Jul 8, 2026

Copy link
Copy Markdown
Member

Closes #374.

What

Adds a public, ordered middleware chain around MCP tool invocation so cross-cutting concerns (logging, quotas, rate limiting, cost weighting, downstream 429/circuit-breaking) layer as composed middleware instead of each overwriting FastMCP's single private request_handlers[CallToolRequest] slot.

How

  • core/tool_middleware.py defines the contract (ToolCallContext, ToolCallMiddleware/CallNext, short_circuit_error), compose (outer-to-inner), and install_tool_call_middleware, which wraps the handler once at build time (once per app: a second install raises; a no-op when the list is empty).
  • The composition root owns the middleware list: server.py's default_tool_middlewares(settings) builds the per-profile list and hands it to install_tool_call_middleware(app, ...). The runtime stays out of middleware entirely and owns only the engine, identity, and inbound auth, so core no longer depends on observability.
  • auth/request_identity.py gains caller_identity(request) returning the validated caller's client id and scopes (the type ctx.identity carries), sharing one _authenticated_user reader with require_request_bearer.
  • observability/tool_log_middleware.py ships the first consumer: one structured JSON line per call (tool, outcome, duration, argument keys, client id, request id), emitted to stderr so it never corrupts the stdio JSON-RPC stream. It is seeded by default only under the remote profile; that is a default, not a capability boundary, since the chain installs on every profile.

Design notes

  • Middleware runs in list order, may short-circuit with a canonical tool_error envelope (isError=True, signalling the tool never ran), and reads identity from the per-message request context, never auth_context_var.
  • Middleware sees raw un-coerced arguments (FastMCP registers the terminal with validate_input=False); argument_keys is bounded and values-free, and the bearer and argument values are never logged.
  • The log outcome distinguishes normal control flow (cancelled on client disconnect, elicitation on a URL-elicitation signal) from error, so neither inflates error metrics.
  • The private CallToolRequest slot is no longer the extension surface; the wrap is pinned to mcp==1.25.0 and fails loud if that contract changes.

Deferred / follow-up

  • Subject (the end-user, distinct from client id) is deferred to per-user quotas (Per-user observability and quotas #309), the consumer that keys on it. This is a scope decision, not a feasibility one: both profiles can source subject when it lands (remote from the validated sub claim, local by decoding the configured JWT credential once at startup), so the AC's subject field arrives with that consumer, across both profiles.
  • This supersedes the tool-line handler wrap in Structured request and tool logging #373's structured logging; when Structured request and tool logging #373 lands, its tool-call logger folds onto this chain, and its sub-preserving token subtype is what the remote half of subject would reuse. Structured request and tool logging #373's "logs on stdout" acceptance criterion should reconcile to stderr, since this chain also runs under the stdio transport where stdout carries the JSON-RPC stream.
  • Governance short-circuits currently log as outcome=error; a distinct denied outcome (so quota and rate-limit rejections do not read as failures) lands with the quota consumer.

Testing

  • New unit tests for compose ordering, short-circuit, exception propagation and the outcome taxonomy (cancelled/elicitation/error), the short-circuit envelope shape, the once-per-app install (second install raises) and the empty-chain no-op, caller_identity (including the local-over-HTTP no-user-scope case), default_tool_middlewares per profile, and the tool-log middleware (fields, privacy, stdout silence).
  • Full packages/mcp suite: 1434 passed, 9 skipped (the no-creds live tests). Verified the real build path wraps the handler under remote and is a no-op under local.

Add CallerIdentity (client id + scopes) and caller_identity(request), reading
the validated caller off the request scope without re-decoding the bearer.
Extract a shared _authenticated_user so caller_identity and require_request_bearer
locate the validated user the same way; caller_identity is non-raising so it runs
on every profile (anonymous under stdio/local). Subject is deferred until its
consumer (per-user quotas) exists.
Turn FastMCP's single CallToolRequest handler slot into an ordered, documented
middleware chain so cross-cutting concerns (logging, quotas, rate limiting) layer
without overwriting the private slot. Middleware register on McpRuntime and run
outer-to-inner, may short-circuit with a canonical error result, and see the
caller identity, tool name, bounded argument keys, and a request id.

The chain wraps the handler once at build time (in server.py, the wiring root),
is a no-op when nothing is registered, and is idempotent per app. A structured
tool-log middleware ships as the first consumer, seeded only under the remote
profile and emitting JSON to stderr so it never corrupts the stdio stream.
@gbrlcustodio gbrlcustodio changed the title feat(mcp): add tool-call middleware seam (#374) feat(mcp): add tool-call middleware seam Jul 8, 2026
The local static token is a JWT carrying the user subject, so subject is
feasible on both profiles; it is deferred for want of a consumer, not because
it cannot be sourced.
The middleware chain is profile-agnostic; only per-user concerns (quotas, cost
attribution) are hosted-specific, while observability and downstream protection
apply to any deployment. Reframe the module docstring, the logger-seeding comment,
and AGENTS.md so the remote-only logger default reads as a default, not a
capability boundary.
Drop editorial framing and restated rationale from the seam's docstrings,
fix a number-agreement slip, and correct require_request_bearer to say the
read goes through request.scope rather than the request.user property the
refactor stopped using.
Behavioral fixes:
- log-middleware outcome is now ok/error/cancelled/elicitation, so client
  disconnects and URL-elicitation control flow no longer inflate error metrics.
- a repeat install with a different middleware set raises instead of silently
  dropping the newcomers; the marker now snapshots the installed set.
- harden the request_id read so a present non-dict scope["state"] cannot crash
  the chain entry.
- require_request_bearer's error names both causes (no valid bearer, or auth
  middleware not wired), recovering the signal the old request.user assertion gave.

Accompanying tidy-ups in the same files:
- ToolCallContext.tool_name/arguments are read-only views over req.params, so the
  context cannot drift from the request it wraps.
- guard the log emit at the call site so the event dict is not built when INFO is
  off; drop an over-defensive getattr in _outcome; drop a redundant list() in
  compose.
- reword an as-is doc line in AGENTS.md that described the seam as a delta.
The runtime seeded a profile-specific logger into a registration list that
server.py then read back out and installed, which coupled core to a concrete
observability middleware and made the list a pointless round-trip.

Move the seeding decision to default_tool_middlewares(settings) in server.py and
pass the list straight to install_tool_call_middleware. Drop the runtime's
_tool_middlewares list, register_tool_middleware, and tool_middlewares property:
the composition root builds the list and installs it, so the runtime owns only
the engine, identity, and inbound auth again. The core -> observability import is
gone; the dependency now runs one way.
The install marker snapshotted the middleware tuple and compared it on
re-install, buying a same-set-is-a-no-op tolerance no caller reaches (install
runs once per app at the composition root) at the cost of object-identity
equality semantics on callables. Replace it with a boolean marker that raises
on any second install: simpler, and still fail-loud against silently stacking
or dropping middleware. Also drop CallerIdentity from the module __all__; its
home is auth.request_identity and nothing imports it through this module.
@gbrlcustodio

Copy link
Copy Markdown
Member Author

Follow-up: make the seam publicly registerable, and use it to make FastMCP an implementation detail

Context: I consumed this branch end to end in a real remote deployment (a thin Kubernetes serving layer over pipefy-mcp-server) to dogfood the seam. It works: build_pipefy_mcp_server installs the chain and the tool logger fires under the remote profile, on mcp==1.28.1 as well as the pinned 1.25.0. Two concrete gaps surfaced, and they point at a larger opportunity that I think is the real value of this work.

Gap 1: the seam is installed but not externally registerable

The PR body says "McpRuntime.register_tool_middleware is the public registration seam" and "server.py reads runtime.tool_middlewares." Neither is in the code. runtime.py has no middleware references, server.py seeds a hardcoded default_tool_middlewares(settings), and install_tool_call_middleware raises on a second install. So a consumer of build_pipefy_mcp_server cannot add its own middleware through the public path: you get the built-in logger or you reach back into app._mcp_server.request_handlers, which is exactly the private slot this PR set out to retire.

Concrete motivating case: the deployment needed per-tool Prometheus metrics (a second consumer of the same seam). With no registration point, the only options were to re-wrap the private handler (defeats the PR) or drop the metrics (what I did). The seam solved the collision problem internally but did not expose the solution.

Gap 2: ToolCallContext leaks the SDK type it is meant to hide

ToolCallContext carries req: types.CallToolRequest and exposes arguments as the SDK-typed map. So a middleware author writes against mcp.types, and the wrap targets app._mcp_server.request_handlers[CallToolRequest] pinned to mcp==1.25.0 ("fails loud if that contract changes"). A downstream that consumes this package resolved mcp==1.28.1 in practice. It happened to still work, but that means the SDK version and the low-level handler shape are currently consumer-facing concerns, not internal ones.

The opportunity: FastMCP as an implementation detail

Today FastMCP is effectively the public API. build_pipefy_mcp_server returns a FastMCP, and every extension reaches into it: custom_route, _mcp_server.request_handlers, settings.transport_security, streamable_http_app(). This seam is the first chance to invert that. The test for whether the boundary holds: could you upgrade the mcp SDK across a breaking change, or swap FastMCP for another MCP server implementation, without any consumer editing code? Right now the answer is no. It should be yes.

A shape that gets there, and that also simplifies the current surface rather than widening it:

@dataclass(frozen=True)
class Plugin:
    """A composable unit of extension. Every field optional; compose many."""
    tool_middlewares: Sequence[ToolCallMiddleware] = ()
    http_middlewares: Sequence[ASGIMiddleware] = ()   # applied outer-to-inner
    routes: Sequence[Route] = ()                       # e.g. /healthz, /metrics
    # session_store: SessionStore | None = None        # the stateless/scaling seam, later

def build_app(settings: Settings, *plugins: Plugin) -> ASGIApp:
    """Assemble a ready-to-serve ASGI app from settings and plugins.

    Folds a built-in core plugin (resource-server wiring, transport security from
    settings, the tool logger under remote) with the caller's plugins, installs the
    tool-call chain once, mounts routes, wraps http middleware, returns the ASGI app.
    No bind and no loopback guard: those are a serving-time policy, not a build concern.
    """

Why this is both simpler and more extensible:

  • One public entry point that returns a finished ASGI app. custom_route, streamable_http_app(), and post-construction mutation of transport_security all leave the public surface. FastMCP stops being the API.
  • The built-in logger stops being a privileged hardcoded path and becomes the first plugin, composed the same way a consumer's plugin is. default_tool_middlewares becomes core_plugin(settings).
  • Plugins compose (observability, quotas, a consumer's metrics) with no plugin aware of the others, and adding a capability is a new field or a new plugin, never a change to build_app's signature. That is the Open/Closed property a tool_middlewares= parameter alone would not give.
  • The mcp pin and the request_handlers reach become purely internal: adapted once, in one place, when the SDK bumps, invisible downstream.

For this to actually hold, ToolCallContext must be pipefy-owned: tool_name: str, arguments: Mapping[str, Any], identity: CallerIdentity, request_id. The raw CallToolRequest should be removed from the public contract or demoted to an explicitly-unstable escape hatch (see below). Otherwise the seam still binds middleware authors to mcp.types and the boundary leaks.

How three consumers collapse onto it:

# CLI
serve_http(build_app(settings), bind=settings.bind)   # loopback guard lives in serve_http

# the remote deployment wrapper, replacing its monkeypatch + mutation entirely
build_app(settings, Plugin(
    tool_middlewares=[prometheus_tool_middleware],
    routes=[Route("/healthz", health), Route("/metrics", metrics)],
    http_middlewares=[AccessLogMiddleware],
))

# tests
build_app(settings, Plugin(tool_middlewares=[capture]))

The discipline this requires

Hiding FastMCP means pipefy_mcp owns every bit of surface it exposes, and anything it does not surface (elicitation, progress, resources, prompts, future SDK features) is unreachable without breaking the abstraction. So the design should include one deliberate, documented, "unstable, may change with the SDK" escape hatch back to the underlying app for the long tail. Without it, "implementation detail" becomes "cage" and people reach around the boundary again.

Suggested scope for this PR vs later

  • In this PR: make registration public (fold consumer plugins/middlewares into the install alongside the built-ins, with a documented order), and make ToolCallContext pipefy-owned so the seam does not leak mcp.types. These are the two things that make the seam actually usable by a second consumer.
  • Follow-up: the full Settings/Plugin/build_app shape and demoting FastMCP behind it, plus serve_http owning the bind policy. Keep build_pipefy_mcp_server(settings) as a thin shim for one release.

Related, separable

Consuming this branch also required pinning pipefy, pipefy-auth, and pipefy-infra to the branch, because the workspace = true siblings are ahead of the identically-versioned PyPI wheels (the branch SDK adds PipefyEngine, which runtime.py imports; the PyPI 0.3.0a1 wheel does not, so it crashes at import). "Open to extension" also means "installable by an extender," which the shared non-immutable 0.3.0a1 version currently blocks. That is a packaging concern separate from this PR, worth its own issue.

@gbrlcustodio gbrlcustodio requested a review from adriannoes July 8, 2026 22:29
@gbrlcustodio gbrlcustodio self-assigned this Jul 8, 2026
@gbrlcustodio gbrlcustodio added the enhancement New feature or request label Jul 8, 2026
@gbrlcustodio gbrlcustodio added this to the Hosted server foundation milestone Jul 8, 2026
Add an optional extra_tool_middlewares to build_pipefy_mcp_server so a
consumer of the builder (a hosted serving layer wanting per-tool metrics,
say) folds its own middleware into the single install rather than reaching
into the private request_handlers slot. The built-in defaults run outer to
the consumer's, so the default observability layer records every call,
including those a consumer's middleware short-circuits.
…lope

Correct the short_circuit_error docstring: it does not replicate FastMCP's
dict normalization byte-for-byte. A dict-returning tool with no output schema
runs through FastMCP's own encoder (non-ASCII left raw, structuredContent
unset), while short_circuit_error uses json.dumps and fills structuredContent.
The match is on the decoded envelope, not the bytes.

Add a contract test that drives a real dict-returning tool through the terminal
and asserts the decoded envelope equals short_circuit_error's, with the intended
isError divergence, so an SDK encoder change fails loud rather than drifting
the two apart silently.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant