Skip to content

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

Merged
gbrlcustodio merged 11 commits into
devfrom
feat/374-tool-call-middleware
Jul 9, 2026
Merged

feat(mcp): add tool-call middleware seam#378
gbrlcustodio merged 11 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.
adriannoes
adriannoes previously approved these changes Jul 9, 2026

@adriannoes adriannoes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This lands the tool-call middleware seam the hosted stack needs: an ordered chain around FastMCP’s single CallToolRequest slot, with short-circuit, request-scoped identity, and tool_log_middleware as the first consumer. The public registration path via build_pipefy_mcp_server(..., extra_tool_middlewares=...) is the right shape for a second consumer without re-wrapping private handlers. Tests and CI look solid on this head.

What worked well

  • Builder fold-in with built-ins outer and a real session test that proves consumer middleware runs.
  • Composition root owns the list; core stays free of observability.
  • Short-circuit envelope parity with in-tool tool_error (decoded JSON, intentional isError divergence).
  • Clear outcome taxonomy on the logger (ok / error / cancelled / elicitation) and stderr-safe emission.

Also noted

  • ToolCallContext and mcp.types: req is still public (your Gap 2). Not blocking if the pipefy-owned context lands with the Plugin / build_app follow-up — worth confirming that plan so consumers don’t treat req as stable API.
  • JSON line integrity (inline): under the default FastMCP/RichHandler config, a realistic tool_call event wraps in non-TTY logs. Sequencing question for #373’s emitter vs a dedicated handler on this middleware — details on the thread.

Comment thread packages/mcp/src/pipefy_mcp/observability/tool_log_middleware.py

@adriannoes adriannoes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the docstring plus #373/#375 ownership on the log-line contract closes the thread. Re-approving on this head.

@gbrlcustodio
gbrlcustodio marked this pull request as ready for review July 9, 2026 17:25
@gbrlcustodio
gbrlcustodio merged commit f55e2fb into dev Jul 9, 2026
2 checks passed
@gbrlcustodio
gbrlcustodio deleted the feat/374-tool-call-middleware branch July 9, 2026 17:51
adriannoes added a commit that referenced this pull request Jul 9, 2026
Keep the stdlib-only JSON emitter package surface from this stack and the
tool-call middleware composition root from #378; wire both into server.py.
adriannoes added a commit that referenced this pull request Jul 9, 2026
Reuse build_tool_call_event/emit_structured_event so #378 tool lines share the
pinned stderr JSON channel and allowlisted shape (including cancelled and
elicitation outcomes) instead of a parallel logger.info path.
adriannoes added a commit that referenced this pull request Jul 9, 2026
Stack 3/3 no longer adds a second CallToolRequest tool logger: #378's
tool_log_middleware already emits through the shared structured emitter.
Keep the correlation e2e (adapted to stderr + middleware) and the user-facing
changelog entry for #373.
adriannoes added a commit that referenced this pull request Jul 14, 2026
…ack 2/3) (#376)

* feat(mcp): add stdout JSON structured logging emitter

Introduce allowlisted JSON event builders and a dedicated stdout logger
with propagate=False so hosted observability coexists with FastMCP's
RichHandler. Add PIPEFY_MCP_LOG_LEVEL to McpSettings and wire it through
run_server and FastMCP construction.

* feat(mcp): preserve JWT sub claim in PipefyAccessToken

Extend the resource-server token mapping with an optional sub field so
hosted HTTP request logs can emit caller subject alongside client_id
without re-decoding the bearer.

* fix(mcp): map log levels explicitly and re-export the emitter surface

normalize_log_level resolved names via getattr(logging, ...), which returns
non-level module attributes (BASIC_FORMAT) and accepts aliases the settings
Literal rejects (WARN, FATAL); an explicit map fails loudly on both. The
observability package now re-exports its stdlib-only emitter surface per the
package convention, and a suite-wide autouse fixture drops leftover stdout
handlers so a test that configures the process-global logger cannot leak a
closed-stream handler into later tests.

* fix(mcp): degrade a non-string JWT sub to None instead of rejecting the token

PipefyAccessToken.sub is str | None, so a validly-signed token carrying a
numeric sub raised ValidationError inside _to_access_token and verify_token
turned it into a 401, a token dev accepted before the field existed. sub
feeds request logging only; a malformed value must not gate authentication.
RFC 7519 requires StringOrURI, so only non-compliant IdPs ever hit this.

* feat(mcp): add pure-ASGI HTTP request log middleware

Emit one structured JSON line per HTTP request from middleware that only
inspects response.start, stores request_id in scope state, and reads
caller identity from the auth context at emit time.

* feat(mcp): serve HTTP with request logging via manual uvicorn

Wire RequestLogMiddleware through streamable_http_app once and replace
FastMCP.run(streamable-http) with explicit uvicorn serve and access_log
disabled so structured request lines replace text access logs.

* fix(mcp): configure the stdout emitter only on the HTTP serve path

configure_observability_logging ran before the transport branch, arming a
stdout handler in the stdio process too, where stdout is the JSON-RPC wire.
Nothing emits under stdio today, but the D10 guarantee was conventional
rather than structural: one future emit call in shared code would corrupt
the protocol. The call now lives in _serve_streamable_http, and the stdio
test asserts configuration never happens there.

Also imports AuthenticatedUser from its defining module (bearer_auth) and
extends the middleware tests: bearer absent from output with an
Authorization header planted, UnauthenticatedUser yields null identity, the
ordering test fails fast instead of hanging, and a send that raises
mid-stream still emits exactly one line.

* fix(mcp): emit structured JSON logs on stderr

Route the hosted observability handler to stderr so structured lines never
share the stdio JSON-RPC stdout channel. Collectors already capture both
streams; HTTP-only configure remains the gate that keeps local installs clean.

* feat(mcp): honor inbound x-request-id and x-correlation-id

Prefer an upstream request or correlation id when present so hosted request
logs stay correlated across service boundaries; mint a UUID only when both
headers are absent or blank.

* fix(mcp): pin structured logger at INFO independent of text level

PIPEFY_MCP_LOG_LEVEL now governs only the FastMCP root logger. The hosted
structured emitter stays at INFO so quieting noisy text logs cannot silently
drop request/tool debugging lines. Docs frame these events as debugging with
a privacy allowlist, not an audit trail.

* refactor(mcp): route tool_log_middleware through the structured emitter

Reuse build_tool_call_event/emit_structured_event so #378 tool lines share the
pinned stderr JSON channel and allowlisted shape (including cancelled and
elicitation outcomes) instead of a parallel logger.info path.
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.

2 participants