From 45faecfd145fe89efd37dbd9289df9eff0a9861c Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:51:34 +0000 Subject: [PATCH 1/2] feat: add middleware= hook to server builders (closes #6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread an optional middleware= (a single FastMCP middleware or a list) through mk_mcp_server / mk_mcp_from_refs / mk_mcp_from_store / mk_http_app / serve_http / serve_stdio, attached at FastMCP construction — mirroring how auth= is threaded. This is the clean seam for cross-cutting concerns that must wrap every tool call (usage metering, cost logging, audit, rate limiting), replacing error-prone per-tool decorators. The no-middleware path is unchanged (the kwarg is only added when given). Adds tests (incl. an in-memory functional test asserting the hook fires around a tool call) and README docs. Claude-Session: https://claude.ai/code/session_01Gw5RPgrQhC88Hc3DyACYWF --- README.md | 20 +++++++++ py2mcp/base.py | 22 +++++++++ py2mcp/http.py | 25 ++++++++--- py2mcp/main.py | 30 +++++++++++-- py2mcp/serve.py | 9 +++- tests/test_middleware.py | 96 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 189 insertions(+), 13 deletions(-) create mode 100644 tests/test_middleware.py diff --git a/README.md b/README.md index 4cf6c43..2bacc62 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,26 @@ app = mk_http_app(["mypkg.tools:summarize"], name="My Connector", auth=AUTH) `serve_http(...)` builds and runs it in-process (FastMCP/uvicorn). Both wrap FastMCP's native transports/OAuth — py2mcp does not reinvent them. +## Middleware (metering, logging, rate-limiting) + +Every builder accepts `middleware=` — a single [FastMCP middleware](https://gofastmcp.com/servers/middleware) or a list — attached at construction, exactly as `auth=` is. It's the one clean seam for cross-cutting concerns that must wrap *every* tool call (usage metering, cost logging, audit trails, rate limiting), so you don't decorate each function individually — and can't forget one (a missed decorator on a paid tool means untracked cost): + +```python +from fastmcp.server.middleware import Middleware + +class UsageMeter(Middleware): + async def on_call_tool(self, context, call_next): + result = await call_next(context) # the tool runs here + record(context.message.name) # ... then meter it + return result + +mcp = mk_mcp_server([render, estimate], middleware=[UsageMeter()]) +# same on mk_mcp_from_refs(...), mk_http_app(...), serve_http(...), serve_stdio(...) +``` + +On the remote path `auth=` (transport-level) runs first, so a middleware can read +the authenticated caller via `fastmcp.server.dependencies.get_access_token()`. + ## License MIT diff --git a/py2mcp/base.py b/py2mcp/base.py index d67187d..4281df9 100644 --- a/py2mcp/base.py +++ b/py2mcp/base.py @@ -47,3 +47,25 @@ def _normalize_to_iterable(funcs: Any) -> Iterable[Callable]: raise TypeError( f"Expected callable or iterable of callables, got {type(funcs)}" ) + + +def _normalize_middleware(middleware: Any) -> Optional[list]: + """Normalize ``middleware`` to a list for FastMCP (or ``None``). + + Accepts ``None`` (no middleware), a single FastMCP ``Middleware``, or a + list/tuple of them — mirroring how ``auth`` is a single optional object. + + >>> _normalize_middleware(None) is None + True + >>> class M: pass + >>> m = M() + >>> _normalize_middleware(m) == [m] + True + >>> _normalize_middleware([m, m]) == [m, m] + True + """ + if middleware is None: + return None + if isinstance(middleware, (list, tuple)): + return list(middleware) + return [middleware] diff --git a/py2mcp/http.py b/py2mcp/http.py index a777055..d8c3019 100644 --- a/py2mcp/http.py +++ b/py2mcp/http.py @@ -142,6 +142,7 @@ def mk_http_app( transport: str = DFLT_TRANSPORT, path: Optional[str] = None, stateless_http: Optional[bool] = None, + middleware: Optional[Any] = None, ) -> Any: """Build a Streamable-HTTP **ASGI app** from ``refs`` (+ optional OAuth). @@ -153,13 +154,18 @@ def mk_http_app( # then: uvicorn server.app:app --host 0.0.0.0 --port 8000 ``auth`` is resolved by :func:`mk_auth_provider` (``None`` → no auth; a remote - connector should always set it). ``stateless_http=True`` is recommended behind - a load balancer (MCP sessions are stateful, so default in-memory sessions break - across replicas — go stateless or externalize session state). Builds the app - with **no network I/O**. + connector should always set it). ``middleware`` (a single FastMCP middleware or + a list) is attached for cross-cutting concerns — metering, logging, rate + limiting — and, because ``auth`` runs first, can read the authenticated caller + via ``fastmcp.server.dependencies.get_access_token()``. ``stateless_http=True`` + is recommended behind a load balancer (MCP sessions are stateful, so default + in-memory sessions break across replicas — go stateless or externalize session + state). Builds the app with **no network I/O**. """ provider = mk_auth_provider(auth) - server = mk_mcp_from_refs(refs, name=name, input_trans=input_trans, auth=provider) + server = mk_mcp_from_refs( + refs, name=name, input_trans=input_trans, auth=provider, middleware=middleware + ) http_kwargs: dict[str, Any] = {"transport": transport} if path is not None: http_kwargs["path"] = path @@ -178,16 +184,21 @@ def serve_http( input_trans: Optional[Callable[[dict], dict]] = None, transport: str = DFLT_TRANSPORT, stateless_http: Optional[bool] = None, + middleware: Optional[Any] = None, ) -> None: """Build and **run** a Streamable-HTTP MCP server (blocking) via FastMCP/uvicorn. For a self-hosted process. Binds ``127.0.0.1`` by default — expose a public interface only behind a TLS-terminating reverse proxy (a remote connector must be reachable over public **HTTPS**, and binding locally is the spec's - DNS-rebinding-safe default). ``auth`` is resolved by :func:`mk_auth_provider`. + DNS-rebinding-safe default). ``auth`` is resolved by :func:`mk_auth_provider`; + ``middleware`` (a single FastMCP middleware or a list) is attached as in + :func:`mk_http_app`. """ provider = mk_auth_provider(auth) - server = mk_mcp_from_refs(refs, name=name, input_trans=input_trans, auth=provider) + server = mk_mcp_from_refs( + refs, name=name, input_trans=input_trans, auth=provider, middleware=middleware + ) run_kwargs: dict[str, Any] = {"transport": transport, "host": host, "port": port} if stateless_http is not None: run_kwargs["stateless_http"] = stateless_http diff --git a/py2mcp/main.py b/py2mcp/main.py index e587904..f66b063 100644 --- a/py2mcp/main.py +++ b/py2mcp/main.py @@ -3,7 +3,11 @@ from typing import Callable, Iterable, Optional, MutableMapping, Any from fastmcp import FastMCP -from py2mcp.base import _normalize_to_iterable, _wrap_with_input_trans +from py2mcp.base import ( + _normalize_to_iterable, + _wrap_with_input_trans, + _normalize_middleware, +) from py2mcp.util import import_object, store_to_funcs @@ -13,6 +17,7 @@ def mk_mcp_server( name: str = "py2mcp Server", input_trans: Optional[Callable[[dict], dict]] = None, auth: Optional[Any] = None, + middleware: Optional[Any] = None, ) -> FastMCP: """Create an MCP server from Python functions. @@ -27,6 +32,13 @@ def mk_mcp_server( used by the remote (HTTP) path for OAuth 2.1 (see :mod:`py2mcp.http`). ``None`` (the default) leaves the server unauthenticated, which is correct for the local stdio path. + middleware: Optional FastMCP middleware (a single middleware or a list), + attached at construction, for cross-cutting concerns that must wrap + *every* tool call — usage metering, cost logging, audit, rate limiting. + Preferred over decorating each tool: you can't forget to wrap one (a + missed paid tool means untracked cost). On the remote path ``auth`` + runs first, so a middleware can read the authenticated caller via + ``fastmcp.server.dependencies.get_access_token()``. Returns: A FastMCP server instance ready to run @@ -45,7 +57,13 @@ def mk_mcp_server( >>> mcp.name 'Math & Greetings' """ - mcp = FastMCP(name, auth=auth) + # Pass ``middleware`` only when given, so the common no-middleware path stays + # the old ``FastMCP(name, auth=auth)`` call (no new fastmcp-version floor). + server_kwargs: dict[str, Any] = {"auth": auth} + middleware_list = _normalize_middleware(middleware) + if middleware_list is not None: + server_kwargs["middleware"] = middleware_list + mcp = FastMCP(name, **server_kwargs) # Normalize to list of functions func_list = list(_normalize_to_iterable(funcs)) @@ -68,6 +86,7 @@ def mk_mcp_from_refs( name: str = "py2mcp Server", input_trans: Optional[Callable[[dict], dict]] = None, auth: Optional[Any] = None, + middleware: Optional[Any] = None, ) -> FastMCP: """Create an MCP server from ``'module:function'`` reference strings. @@ -83,7 +102,9 @@ def mk_mcp_from_refs( 'Paths' """ funcs = [import_object(ref) for ref in refs] - return mk_mcp_server(funcs, name=name, input_trans=input_trans, auth=auth) + return mk_mcp_server( + funcs, name=name, input_trans=input_trans, auth=auth, middleware=middleware + ) def mk_mcp_from_store( @@ -92,6 +113,7 @@ def mk_mcp_from_store( name: str = "item", plural: str = "", server_name: Optional[str] = None, + middleware: Optional[Any] = None, ) -> FastMCP: """Create an MCP server from a MutableMapping with CRUD operations. @@ -117,4 +139,4 @@ def mk_mcp_from_store( funcs = store_to_funcs(store, name=name, plural=plural) - return mk_mcp_server(funcs, name=server_name) + return mk_mcp_server(funcs, name=server_name, middleware=middleware) diff --git a/py2mcp/serve.py b/py2mcp/serve.py index e30ea6f..3433258 100644 --- a/py2mcp/serve.py +++ b/py2mcp/serve.py @@ -81,14 +81,19 @@ def serve_stdio( *, name: str = DFLT_SERVER_NAME, input_trans: Optional[Callable[[dict], dict]] = None, + middleware: Optional[Any] = None, ) -> None: """Build an MCP server from ``'module:function'`` refs and run it over stdio. Blocks, serving the MCP protocol on stdin/stdout until the host disconnects. Thin wrapper over :func:`py2mcp.mk_mcp_from_refs` + ``FastMCP.run`` so that - packaged integrations have one command to launch. + packaged integrations have one command to launch. ``middleware`` (a single + FastMCP middleware or a list) is forwarded for cross-cutting concerns — + logging/metering is as useful on the local stdio path as on the remote one. """ - server = mk_mcp_from_refs(refs, name=name, input_trans=input_trans) + server = mk_mcp_from_refs( + refs, name=name, input_trans=input_trans, middleware=middleware + ) server.run(transport="stdio") diff --git a/tests/test_middleware.py b/tests/test_middleware.py new file mode 100644 index 0000000..5bf8752 --- /dev/null +++ b/tests/test_middleware.py @@ -0,0 +1,96 @@ +"""Tests for the ``middleware=`` hook threaded through the server builders. + +py2mcp attaches FastMCP middleware at construction — the seam for cross-cutting +concerns (metering, logging, rate limiting) that must wrap every tool call. These +verify the hook is (a) attached by every builder and (b) actually fires around a +real tool invocation (via an in-memory client), plus the single-vs-list +normalization. See i2mint/py2mcp#6. +""" + +import asyncio + +from fastmcp import Client +from fastmcp.server.middleware import Middleware + +from py2mcp import mk_mcp_server, mk_mcp_from_refs +from py2mcp.http import mk_http_app + + +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + +class _Recorder(Middleware): + """A middleware that records the name of every tool call it wraps.""" + + def __init__(self): + self.calls = [] + + async def on_call_tool(self, context, call_next): + self.calls.append(context.message.name) + return await call_next(context) + + +def test_single_middleware_is_attached(): + rec = _Recorder() + server = mk_mcp_server([add], middleware=rec) # single, not a list + assert rec in server.middleware + + +def test_list_of_middleware_is_attached(): + a, b = _Recorder(), _Recorder() + server = mk_mcp_server([add], middleware=[a, b]) + assert a in server.middleware and b in server.middleware + + +def test_no_middleware_still_builds(): + server = mk_mcp_server([add]) + assert isinstance(server.middleware, list) # only FastMCP's own defaults + + +def test_middleware_fires_around_a_tool_call(): + # The acceptance test from issue #6: the hook actually wraps the call. + rec = _Recorder() + server = mk_mcp_server([add], middleware=[rec]) + + async def go(): + async with Client(server) as client: + return await client.call_tool("add", {"a": 2, "b": 3}) + + result = asyncio.run(go()) + assert result.data == 5 # the tool ran + assert rec.calls == ["add"] # ... and the middleware saw it + + +def test_mk_mcp_from_refs_forwards_middleware(): + rec = _Recorder() + server = mk_mcp_from_refs(["os.path:basename"], name="paths", middleware=rec) + assert rec in server.middleware + + +def test_mk_http_app_accepts_middleware(): + rec = _Recorder() + app = mk_http_app(["os.path:basename"], name="conn", middleware=[rec]) + assert callable(app) # a real ASGI app built with the middleware attached + + +def test_serve_http_forwards_middleware(monkeypatch): + # serve_http is blocking; capture the forwarded kwarg without binding a port. + from py2mcp import http as http_mod + + captured = {} + + class _DummyServer: + def run(self, **kwargs): + captured["ran"] = True + + def _fake_mk(refs, **kwargs): + captured.update(kwargs) + return _DummyServer() + + monkeypatch.setattr(http_mod, "mk_mcp_from_refs", _fake_mk) + rec = _Recorder() + http_mod.serve_http(["os.path:basename"], name="conn", middleware=rec) + assert captured["middleware"] is rec + assert captured["ran"] is True From bdeb6f533837439156e16f0e1f379dd52768d3e7 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:03:16 +0000 Subject: [PATCH 2/2] fix(review): broaden middleware normalization to any iterable + close doc/test gaps Adversarial-review follow-ups (issue #6): - _normalize_middleware now accepts any iterable of middlewares (set, generator, ...), not just list/tuple. A Middleware instance is matched first so an iterable-ish one isn't mistaken for a collection; generators are materialized. Previously a set/generator was silently wrapped as one bogus element -> the server built fine but every tool call failed with an opaque McpError. - An empty middleware iterable now takes the plain FastMCP(name, auth=auth) path. - Document middleware= on mk_mcp_from_store and mk_mcp_from_refs; README enumerates every builder, says 'iterable', and notes middleware is a programmatic-only hook. - Tests: generator/set/empty coverage, mk_mcp_from_store + serve_stdio forwarding, and mk_http_app forwarding now asserted precisely (not just a smoke build). Claude-Session: https://claude.ai/code/session_01Gw5RPgrQhC88Hc3DyACYWF --- README.md | 7 ++-- py2mcp/base.py | 17 +++++++-- py2mcp/main.py | 14 +++++--- tests/test_middleware.py | 77 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 102 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 2bacc62..e86f69c 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ FastMCP's native transports/OAuth — py2mcp does not reinvent them. ## Middleware (metering, logging, rate-limiting) -Every builder accepts `middleware=` — a single [FastMCP middleware](https://gofastmcp.com/servers/middleware) or a list — attached at construction, exactly as `auth=` is. It's the one clean seam for cross-cutting concerns that must wrap *every* tool call (usage metering, cost logging, audit trails, rate limiting), so you don't decorate each function individually — and can't forget one (a missed decorator on a paid tool means untracked cost): +Every builder accepts `middleware=` — a single [FastMCP middleware](https://gofastmcp.com/servers/middleware) or an iterable of them — attached at construction, exactly as `auth=` is. It's the one clean seam for cross-cutting concerns that must wrap *every* tool call (usage metering, cost logging, audit trails, rate limiting), so you don't decorate each function individually — and can't forget one (a missed decorator on a paid tool means untracked cost): ```python from fastmcp.server.middleware import Middleware @@ -119,11 +119,14 @@ class UsageMeter(Middleware): return result mcp = mk_mcp_server([render, estimate], middleware=[UsageMeter()]) -# same on mk_mcp_from_refs(...), mk_http_app(...), serve_http(...), serve_stdio(...) +# same on mk_mcp_from_refs(...), mk_mcp_from_store(...), mk_http_app(...), +# serve_http(...), serve_stdio(...) ``` On the remote path `auth=` (transport-level) runs first, so a middleware can read the authenticated caller via `fastmcp.server.dependencies.get_access_token()`. +Middleware is a *programmatic* hook — it takes Python objects, so it isn't wired +through the `python -m py2mcp` CLI / JSON-config path (unlike `refs`/`name`/`auth`). ## License diff --git a/py2mcp/base.py b/py2mcp/base.py index 4281df9..a9a65fa 100644 --- a/py2mcp/base.py +++ b/py2mcp/base.py @@ -52,8 +52,11 @@ def _normalize_to_iterable(funcs: Any) -> Iterable[Callable]: def _normalize_middleware(middleware: Any) -> Optional[list]: """Normalize ``middleware`` to a list for FastMCP (or ``None``). - Accepts ``None`` (no middleware), a single FastMCP ``Middleware``, or a - list/tuple of them — mirroring how ``auth`` is a single optional object. + Accepts ``None`` (no middleware), a single FastMCP ``Middleware``, or any + iterable of them (list, tuple, set, generator) — mirroring how + :func:`_normalize_to_iterable` handles ``funcs``. A single ``Middleware`` is + matched first, so one that happens to be iterable is not mistaken for a + collection; a generator is materialized so it survives being forwarded on. >>> _normalize_middleware(None) is None True @@ -63,9 +66,17 @@ def _normalize_middleware(middleware: Any) -> Optional[list]: True >>> _normalize_middleware([m, m]) == [m, m] True + >>> _normalize_middleware((m, m)) == [m, m] # tuple -> list + True + >>> _normalize_middleware(iter([m, m])) == [m, m] # any iterable is materialized + True """ if middleware is None: return None - if isinstance(middleware, (list, tuple)): + from fastmcp.server.middleware import Middleware + + if isinstance(middleware, Middleware): + return [middleware] + if isinstance(middleware, Iterable) and not isinstance(middleware, (str, bytes)): return list(middleware) return [middleware] diff --git a/py2mcp/main.py b/py2mcp/main.py index f66b063..6559f53 100644 --- a/py2mcp/main.py +++ b/py2mcp/main.py @@ -57,11 +57,12 @@ def mk_mcp_server( >>> mcp.name 'Math & Greetings' """ - # Pass ``middleware`` only when given, so the common no-middleware path stays - # the old ``FastMCP(name, auth=auth)`` call (no new fastmcp-version floor). + # Pass ``middleware`` only when there is some, so the no-middleware (and + # empty-list) path stays the old ``FastMCP(name, auth=auth)`` call (no new + # fastmcp-version floor). server_kwargs: dict[str, Any] = {"auth": auth} middleware_list = _normalize_middleware(middleware) - if middleware_list is not None: + if middleware_list: server_kwargs["middleware"] = middleware_list mcp = FastMCP(name, **server_kwargs) @@ -93,8 +94,8 @@ def mk_mcp_from_refs( Resolves each reference to a callable via :func:`py2mcp.util.import_object` and delegates to :func:`mk_mcp_server`. One call from config strings to a runnable server — what tools that read tool references from a file (e.g. - ``coact``'s ``mcp`` backend) need. ``auth`` is forwarded to - :func:`mk_mcp_server` (the remote/HTTP path attaches an OAuth provider here). + ``coact``'s ``mcp`` backend) need. ``auth`` and ``middleware`` are forwarded + to :func:`mk_mcp_server` (the remote/HTTP path attaches an OAuth provider here). Examples: >>> mcp = mk_mcp_from_refs(['os.path:basename', 'os.path:dirname'], name='Paths') @@ -124,6 +125,9 @@ def mk_mcp_from_store( name: Singular name for items (e.g., 'project', 'user') plural: Plural form (defaults to name + 's') server_name: Name of the MCP server (defaults to "{name} Store") + middleware: Optional FastMCP middleware (a single middleware or an + iterable), forwarded to :func:`mk_mcp_server` — wraps every generated + CRUD tool call, e.g. to meter or audit store reads and mutations. Returns: A FastMCP server with CRUD operations diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 5bf8752..d1f2ada 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -12,7 +12,7 @@ from fastmcp import Client from fastmcp.server.middleware import Middleware -from py2mcp import mk_mcp_server, mk_mcp_from_refs +from py2mcp import mk_mcp_server, mk_mcp_from_refs, mk_mcp_from_store from py2mcp.http import mk_http_app @@ -69,10 +69,31 @@ def test_mk_mcp_from_refs_forwards_middleware(): assert rec in server.middleware -def test_mk_http_app_accepts_middleware(): +def test_mk_http_app_builds_with_middleware(): + # integration: the real fastmcp Streamable-HTTP path accepts middleware. rec = _Recorder() app = mk_http_app(["os.path:basename"], name="conn", middleware=[rec]) - assert callable(app) # a real ASGI app built with the middleware attached + assert callable(app) + + +def test_mk_http_app_forwards_middleware(monkeypatch): + # prove forwarding precisely: capture what reaches mk_mcp_from_refs. + from py2mcp import http as http_mod + + captured = {} + + class _DummyServer: + def http_app(self, **kwargs): + return object() + + def _fake_mk(refs, **kwargs): + captured.update(kwargs) + return _DummyServer() + + monkeypatch.setattr(http_mod, "mk_mcp_from_refs", _fake_mk) + rec = _Recorder() + http_mod.mk_http_app(["os.path:basename"], name="conn", middleware=rec) + assert captured["middleware"] is rec def test_serve_http_forwards_middleware(monkeypatch): @@ -94,3 +115,53 @@ def _fake_mk(refs, **kwargs): http_mod.serve_http(["os.path:basename"], name="conn", middleware=rec) assert captured["middleware"] is rec assert captured["ran"] is True + + +def test_generator_of_middleware_is_attached(): + # regression (#6 review): a non-list/tuple iterable (generator) must be + # materialized and each middleware attached — not wrapped as one bogus element + # that builds fine then fails every tool call. + a, b = _Recorder(), _Recorder() + server = mk_mcp_server([add], middleware=(m for m in (a, b))) + assert a in server.middleware and b in server.middleware + + +def test_set_of_middleware_is_attached(): + a, b = _Recorder(), _Recorder() + server = mk_mcp_server([add], middleware={a, b}) + assert a in server.middleware and b in server.middleware + + +def test_empty_middleware_matches_plain_construction(): + # An empty iterable means "no middleware": it must take the same plain + # construction path as passing nothing (adds no user middleware). + plain = mk_mcp_server([add]).middleware + empty = mk_mcp_server([add], middleware=[]).middleware + assert len(empty) == len(plain) + + +def test_mk_mcp_from_store_forwards_middleware(): + rec = _Recorder() + server = mk_mcp_from_store({}, name="item", middleware=rec) + assert rec in server.middleware + + +def test_serve_stdio_forwards_middleware(monkeypatch): + # serve_stdio is blocking; capture the forwarded kwarg without running a server. + from py2mcp import serve as serve_mod + + captured = {} + + class _DummyServer: + def run(self, **kwargs): + captured["ran"] = True + + def _fake_mk(refs, **kwargs): + captured.update(kwargs) + return _DummyServer() + + monkeypatch.setattr(serve_mod, "mk_mcp_from_refs", _fake_mk) + rec = _Recorder() + serve_mod.serve_stdio(["os.path:basename"], middleware=rec) + assert captured["middleware"] is rec + assert captured["ran"] is True