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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,29 @@ 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 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

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_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

MIT
33 changes: 33 additions & 0 deletions py2mcp/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,36 @@ 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 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
>>> class M: pass
>>> m = M()
>>> _normalize_middleware(m) == [m]
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
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]
25 changes: 18 additions & 7 deletions py2mcp/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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
Expand All @@ -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
Expand Down
38 changes: 32 additions & 6 deletions py2mcp/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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.

Expand All @@ -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
Expand All @@ -45,7 +57,14 @@ def mk_mcp_server(
>>> mcp.name
'Math & Greetings'
"""
mcp = FastMCP(name, auth=auth)
# 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:
server_kwargs["middleware"] = middleware_list
mcp = FastMCP(name, **server_kwargs)

# Normalize to list of functions
func_list = list(_normalize_to_iterable(funcs))
Expand All @@ -68,22 +87,25 @@ 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.

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')
>>> mcp.name
'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(
Expand All @@ -92,6 +114,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.

Expand All @@ -102,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
Expand All @@ -117,4 +143,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)
9 changes: 7 additions & 2 deletions py2mcp/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down
Loading
Loading