Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions packages/mcp/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,65 @@ than a local `file_path`), enforce that in the tool body gated on
`settings.mcp.profile == "remote"` at call time, not via the marker. A tool whose
exclusion deserves a reason gets a plain code comment stating why; the exclusion
itself needs no annotation.

## Tool-call middleware

Cross-cutting concerns that wrap a tool invocation (logging, per-user quotas,
rate limiting, cost weighting, downstream 429/circuit-breaking) register as
ordered middleware, not by overwriting the server's internal handler. The MCP SDK
dispatches every tool call through one `request_handlers[CallToolRequest]` slot;
`core/tool_middleware.py` wraps that slot once, at build time, and composes the
registered middleware around it. The middleware chain is the extension surface;
the private slot is wrapped, not written to directly.

A middleware is a plain async callable. A built-in middleware joins the per-profile
defaults (`default_tool_middlewares` in `server.py`); a consumer of
`build_pipefy_mcp_server` passes its own through `extra_tool_middlewares`, which the
builder folds into the single install after the built-ins (so the default
observability layer stays outermost). Neither path touches FastMCP internals:

```python
from pipefy_mcp.core.tool_middleware import ToolCallContext, CallNext, short_circuit_error

async def quota(ctx: ToolCallContext, call_next: CallNext):
if over_quota(ctx.identity.client_id):
return short_circuit_error("quota exceeded", code="RATE_LIMITED")
return await call_next(ctx)

# a serving layer registers its own middleware through the public builder:
# app = build_pipefy_mcp_server(settings, extra_tool_middlewares=[quota])
```

- **Order**: list order runs outer to inner around the tool. `[A, B]` runs A,
then B, then the tool, and unwinds in reverse.
- **Short-circuit**: a middleware that returns without awaiting `call_next` skips
the inner chain and the tool. Use `short_circuit_error`, which carries the
canonical `tool_error` envelope but sets `isError=True` deliberately: a
governance stop means the tool never ran, distinct from a tool that ran and
reported a business error (`isError=False`).
- **Identity** (`ctx.identity`): the validated caller's `client_id` and `scopes`,
read off the request's bearer, never re-decoded. Empty under stdio/local (no
inbound bearer). The end-user `subject` is intentionally absent until its
consumer (per-user quotas) exists.
- **`request_id`**: correlates a call to its HTTP request when available, else the
JSON-RPC message id, which is client-chosen and only unique within a session.
- **Raw arguments**: FastMCP registers the terminal with `validate_input=False`
and coerces arguments downstream, so middleware sees the un-coerced, client-sent
arguments. `ctx.argument_keys` is bounded (count and length caps) and values-free
for privacy-sensitive consumers; `ctx.arguments` values are passed unbounded to
any consumer that opts to read them. Never log a bearer or argument values.

The chain installs on every profile (a no-op when the list is empty); the
built-in structured logger (`observability/tool_log_middleware.py`) is seeded
by default only under the `remote` profile. That is a default, not a capability
boundary: per-call concerns like observability and downstream protection apply to
any deployment (only per-user concerns are hosted-specific), so a local deployment
can register its own middleware. The logger emits through the `logging` module
(stderr), never stdout, which is the stdio transport's JSON-RPC stream.

The wrap targets a FastMCP internal and is tested against `mcp==1.25.0`; the
install is idempotent per app (the sentinel is per handler, not a global). This is
a separate seam from the argument-validation envelope
(`tools/validation_envelope.py`), which patches `Tool.run` to reshape a pydantic
`ValidationError` inside FastMCP's executor: that error's structured detail exists
only there, below this chain, so the two are complementary, not interchangeable.
69 changes: 59 additions & 10 deletions packages/mcp/src/pipefy_mcp/auth/request_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,31 +18,80 @@

from __future__ import annotations

from dataclasses import dataclass, field

from mcp.server.auth.middleware.auth_context import AuthenticatedUser
from starlette.requests import Request


@dataclass(frozen=True)
class CallerIdentity:
"""The validated caller a tool-call middleware acts on behalf of.

Sourced from the request's validated bearer, never re-decoded. ``client_id``
is the OAuth client (``azp``/``client_id``, falling back to ``sub`` for a
user token) and ``scopes`` its granted scopes. Both default empty: under the
stdio/local profile there is no inbound bearer, so middleware sees an
anonymous identity rather than a failure.

The end-user subject is intentionally deferred: its consumer (per-user
quotas) is not built yet. Both profiles can source it when it lands, the
remote profile from the validated ``sub`` claim and the local profile from
the configured JWT credential.
"""

client_id: str | None = None
scopes: tuple[str, ...] = field(default_factory=tuple)


def _authenticated_user(request: Request | None) -> AuthenticatedUser | None:
"""Return the RS-validated user off the request, or None.

Reads ``request.scope`` directly rather than ``request.user``: the latter
asserts ``AuthenticationMiddleware`` is installed, which holds only under the
``remote`` profile, so touching the property would raise on a ``local`` HTTP
request. This is the one place both request-scoped readers agree on how the
validated caller is located: off the per-message request, never
``auth_context_var``.
"""
user = request.scope.get("user") if request is not None else None
return user if isinstance(user, AuthenticatedUser) else None


def caller_identity(request: Request | None) -> CallerIdentity:
"""Return the validated caller off the in-flight request, or an anonymous one.

Runs on every profile (a tool-call middleware wraps both transports), so a
missing or non-authenticated user yields the empty :class:`CallerIdentity`
rather than an error.
"""
user = _authenticated_user(request)
if user is None:
return CallerIdentity()
token = user.access_token
return CallerIdentity(client_id=token.client_id, scopes=tuple(token.scopes))


def require_request_bearer(request: Request | None) -> str:
"""Return the validated bearer token off the in-flight request.

The resource-server middleware sets ``request.user`` to the
``AuthenticatedUser`` it validated; this reads its access token. Taking the
The resource-server middleware sets the validated ``AuthenticatedUser`` on
the request; this reads its access token. Taking the
request as an argument (the runtime passes ``ctx.request_context.request`` from
the tool handler) keeps the read on the current caller: it never touches
``auth_context_var``, which stateful Streamable HTTP freezes at the session's
first bearer.

Raises when no validated bearer is present (called outside the resource-server
request scope, or the request bore no ``AuthenticatedUser``), so a missing
identity fails loudly instead of issuing an unauthenticated Pipefy call. The
``isinstance`` guard keeps that fail-loud property on a typed contract: an
unauthenticated request carries a different user type, not an
``AuthenticatedUser`` with an empty token.
identity fails loudly instead of issuing an unauthenticated Pipefy call.
"""
user = request.user if request is not None else None
if not isinstance(user, AuthenticatedUser) or not user.access_token.token:
user = _authenticated_user(request)
if user is None or not user.access_token.token:
raise RuntimeError(
"No authenticated access token on the request; the resource-server "
"profile must validate a bearer before a tool runs."
"No authenticated access token on the request. Under the "
"resource-server profile this means either the caller sent no valid "
"bearer or the authentication middleware is not wired into the ASGI "
"stack; a tool must not run without a validated caller."
)
return user.access_token.token
Loading