diff --git a/README.md b/README.md index 7c6870c..4cf6c43 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,44 @@ mcp = mk_mcp_from_store(projects, name="project") # Automatically creates: list_projects, get_project, set_project, delete_project ``` +## Serving: local (stdio) and remote (HTTP + OAuth) + +`mk_mcp_*` build a server *object*; py2mcp also gives you two ways to **run** one. + +**Local (stdio)** — for a one-click bundle (e.g. a Claude Desktop `.mcpb`): + +```python +from py2mcp import serve_stdio +serve_stdio(["mypkg.tools:summarize", "mypkg.tools:translate"], name="My Tools") +# or: python -m py2mcp --config py2mcp_config.json +``` + +**Remote (Streamable HTTP + OAuth 2.1)** — for a hosted MCP server reached from a +vendor's cloud (e.g. a claude.ai custom connector). The server is an OAuth 2.1 +**resource server**: it *validates* a managed IdP's JWTs (audience-bound per +RFC 8707) and never issues tokens itself. + +```python +from py2mcp.http import mk_http_app + +AUTH = { + "type": "jwt", # resource-server: validate the IdP's JWTs + "jwks_uri": "https://idp.example.com/.well-known/jwks.json", + "issuer": "https://idp.example.com", + "audience": "https://my-connector.example.com/mcp", # THIS server (RFC 8707) + "authorization_servers": ["https://idp.example.com"], + "base_url": "https://my-connector.example.com", + "required_scopes": ["mcp:read"], +} + +# An ASGI app you run under any ASGI server (uvicorn, gunicorn, serverless): +app = mk_http_app(["mypkg.tools:summarize"], name="My Connector", auth=AUTH) +# uvicorn server.app:app --host 0.0.0.0 --port 8000 (behind TLS) +``` + +`serve_http(...)` builds and runs it in-process (FastMCP/uvicorn). Both wrap +FastMCP's native transports/OAuth — py2mcp does not reinvent them. + ## License MIT diff --git a/py2mcp/__init__.py b/py2mcp/__init__.py index 290c6ef..4e90833 100644 --- a/py2mcp/__init__.py +++ b/py2mcp/__init__.py @@ -17,6 +17,7 @@ from py2mcp.main import mk_mcp_server, mk_mcp_from_store, mk_mcp_from_refs from py2mcp.serve import serve_stdio, resolve_server_config, load_server_config +from py2mcp.http import mk_http_app, serve_http, mk_auth_provider from py2mcp.trans import mk_input_trans from py2mcp.util import import_object @@ -47,4 +48,8 @@ def _resolve_version() -> str: "serve_stdio", "resolve_server_config", "load_server_config", + # remote (Streamable-HTTP + OAuth resource-server) serving + "mk_http_app", + "serve_http", + "mk_auth_provider", ] diff --git a/py2mcp/http.py b/py2mcp/http.py new file mode 100644 index 0000000..15f995d --- /dev/null +++ b/py2mcp/http.py @@ -0,0 +1,183 @@ +"""Serve a py2mcp ``FastMCP`` server over **Streamable HTTP** with optional +OAuth 2.1 — the *remote* counterpart to :mod:`py2mcp.serve` (stdio). + +A *remote* MCP server (e.g. a claude.ai custom connector) is reached over public +HTTPS from the vendor's cloud and authenticates with **OAuth 2.1**. Per the MCP +authorization spec the MCP server is an OAuth 2.1 **resource server** — it +*validates* bearer tokens minted by a **managed identity provider** (the +authorization server) and **never issues tokens itself**. Two hard rules fall out +of that and are enforced here by construction: + +- **Audience binding (RFC 8707).** The verifier checks the token's ``aud`` equals + *this* server's resource id, so a token minted for another service cannot be + replayed here (the confused-deputy defense). +- **No token passthrough.** This layer only *verifies* the inbound token; it never + forwards it upstream. Any upstream call your tools make must use their own + credentials. + +It wraps FastMCP's native machinery (no transport/OAuth code is reinvented): + +- :func:`mk_auth_provider` — an auth-config dict → a + ``fastmcp.server.auth.RemoteAuthProvider`` (a ``JWTVerifier`` resource server + that validates the IdP's JWTs and publishes the RFC 9728 + ``/.well-known/oauth-protected-resource`` document pointing at the IdP). +- :func:`mk_http_app` — build the server (via :func:`py2mcp.mk_mcp_from_refs`), + attach the auth provider, and return a Streamable-HTTP **ASGI app** to run under + any ASGI server (uvicorn, gunicorn, a serverless adapter). +- :func:`serve_http` — build and *run* it (blocking), for a self-hosted process. + +``coact``'s ``claude-remote-connector`` publish target scaffolds a deployable +service around these — coact writes packaging, py2mcp builds and serves the MCP +server (the same division of labour as the stdio ``.mcpb`` path). +""" + +from __future__ import annotations + +from typing import Any, Callable, Iterable, Optional + +from py2mcp.main import mk_mcp_from_refs +from py2mcp.serve import DFLT_SERVER_NAME + +#: Auth ``type`` values :func:`mk_auth_provider` understands. ``'jwt'`` is the +#: vendor-neutral resource-server pattern (validate a managed IdP's JWTs); +#: managed-provider shortcuts (auth0/workos/...) can be added as new types. +SUPPORTED_AUTH_TYPES = ("jwt",) + +#: Default Streamable-HTTP transport (the current remote MCP transport). +DFLT_TRANSPORT = "streamable-http" + + +def mk_auth_provider(auth: Optional[dict]) -> Optional[Any]: + """Build a FastMCP **resource-server** auth provider from an auth-config dict. + + ``auth`` is ``None``/falsy (no auth) or a dict with a ``type`` key: + + ``type='jwt'`` (default) — validate JWTs issued by a managed IdP. Keys: + + - ``jwks_uri`` *or* ``public_key`` — where to get the IdP's signing key(s). + - ``issuer`` — the IdP issuer URL (the token's ``iss``). + - ``audience`` — **this** server's resource id (the token's ``aud``; RFC 8707 + audience binding that stops a token for another service being replayed here). + - ``authorization_servers`` (or a single ``issuer``) — IdP issuer URL(s) + advertised in the RFC 9728 protected-resource metadata. + - ``base_url`` — this server's public base URL. + - ``required_scopes`` (optional) — scopes every request must carry. + + Returns a ``RemoteAuthProvider`` (a resource server), or ``None`` when ``auth`` + is falsy. Raises ``ValueError`` on an unknown ``type`` or a missing required key. + Building the provider performs **no network I/O** (key fetching is lazy, on the + first request), so this is safe to call at scaffold/import time. + """ + if not auth: + return None + if not isinstance(auth, dict): + raise ValueError("auth must be a dict (or None), e.g. {'type': 'jwt', ...}") + auth_type = auth.get("type", "jwt") + if auth_type != "jwt": + raise ValueError( + f"Unsupported auth type {auth_type!r}. Supported: " + f"{', '.join(SUPPORTED_AUTH_TYPES)}. Use 'jwt' for the resource-server " + "pattern (validate a managed IdP's JWTs); managed-provider shortcuts " + "can be added as new types." + ) + + from fastmcp.server.auth import RemoteAuthProvider + from fastmcp.server.auth.providers.jwt import JWTVerifier + + jwks_uri = auth.get("jwks_uri") + public_key = auth.get("public_key") + if not (jwks_uri or public_key): + raise ValueError( + "jwt auth needs 'jwks_uri' (or 'public_key') to validate token signatures." + ) + base_url = auth.get("base_url") + if not base_url: + raise ValueError( + "jwt auth needs 'base_url' (this server's public base URL, for the " + "RFC 9728 protected-resource metadata)." + ) + authorization_servers = auth.get("authorization_servers") + if not authorization_servers and auth.get("issuer"): + authorization_servers = [auth["issuer"]] + if not authorization_servers: + raise ValueError( + "jwt auth needs 'authorization_servers' (or 'issuer') — the managed IdP " + "that issues tokens, advertised via RFC 9728 discovery." + ) + required_scopes = auth.get("required_scopes") + + verifier = JWTVerifier( + jwks_uri=jwks_uri, + public_key=public_key, + issuer=auth.get("issuer"), + audience=auth.get("audience"), + required_scopes=required_scopes, + base_url=base_url, + ) + return RemoteAuthProvider( + token_verifier=verifier, + authorization_servers=list(authorization_servers), + base_url=base_url, + scopes_supported=required_scopes, + ) + + +def mk_http_app( + refs: Iterable[str], + *, + name: str = DFLT_SERVER_NAME, + auth: Optional[dict] = None, + input_trans: Optional[Callable[[dict], dict]] = None, + transport: str = DFLT_TRANSPORT, + path: Optional[str] = None, + stateless_http: Optional[bool] = None, +) -> Any: + """Build a Streamable-HTTP **ASGI app** from ``refs`` (+ optional OAuth). + + Returns the ASGI application (a Starlette app), so any ASGI server can run it:: + + # server/app.py + from py2mcp.http import mk_http_app + app = mk_http_app(['mypkg.tools:summarize'], name='My Connector', auth=AUTH) + # 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**. + """ + provider = mk_auth_provider(auth) + server = mk_mcp_from_refs(refs, name=name, input_trans=input_trans, auth=provider) + http_kwargs: dict[str, Any] = {"transport": transport} + if path is not None: + http_kwargs["path"] = path + if stateless_http is not None: + http_kwargs["stateless_http"] = stateless_http + return server.http_app(**http_kwargs) + + +def serve_http( + refs: Iterable[str], + *, + name: str = DFLT_SERVER_NAME, + host: str = "127.0.0.1", + port: int = 8000, + auth: Optional[dict] = None, + input_trans: Optional[Callable[[dict], dict]] = None, + transport: str = DFLT_TRANSPORT, + stateless_http: Optional[bool] = 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`. + """ + provider = mk_auth_provider(auth) + server = mk_mcp_from_refs(refs, name=name, input_trans=input_trans, auth=provider) + run_kwargs: dict[str, Any] = {"transport": transport, "host": host, "port": port} + if stateless_http is not None: + run_kwargs["stateless_http"] = stateless_http + server.run(**run_kwargs) diff --git a/py2mcp/main.py b/py2mcp/main.py index a048e30..e587904 100644 --- a/py2mcp/main.py +++ b/py2mcp/main.py @@ -12,6 +12,7 @@ def mk_mcp_server( *, name: str = "py2mcp Server", input_trans: Optional[Callable[[dict], dict]] = None, + auth: Optional[Any] = None, ) -> FastMCP: """Create an MCP server from Python functions. @@ -22,6 +23,10 @@ def mk_mcp_server( funcs: A function or iterable of functions to expose as MCP tools name: Name of the MCP server input_trans: Optional function to transform input kwargs before calling tools + auth: Optional ``fastmcp.server.auth`` provider attached at construction — + 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. Returns: A FastMCP server instance ready to run @@ -40,7 +45,7 @@ def mk_mcp_server( >>> mcp.name 'Math & Greetings' """ - mcp = FastMCP(name) + mcp = FastMCP(name, auth=auth) # Normalize to list of functions func_list = list(_normalize_to_iterable(funcs)) @@ -62,13 +67,15 @@ def mk_mcp_from_refs( *, name: str = "py2mcp Server", input_trans: Optional[Callable[[dict], dict]] = None, + auth: 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. + ``coact``'s ``mcp`` backend) need. ``auth`` is 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') @@ -76,7 +83,7 @@ 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) + return mk_mcp_server(funcs, name=name, input_trans=input_trans, auth=auth) def mk_mcp_from_store( diff --git a/py2mcp/serve.py b/py2mcp/serve.py index bcdb008..0b05221 100644 --- a/py2mcp/serve.py +++ b/py2mcp/serve.py @@ -96,7 +96,8 @@ def main(argv: Optional[list[str]] = None) -> None: """CLI: ``python -m py2mcp --config cfg.json`` (or ``--ref mod:func ...``).""" parser = argparse.ArgumentParser( prog="py2mcp", - description="Serve an MCP server over stdio from 'module:function' refs.", + description="Serve an MCP server from 'module:function' refs — over stdio " + "(default) or Streamable HTTP (--http).", ) parser.add_argument( "--config", @@ -113,6 +114,18 @@ def main(argv: Optional[list[str]] = None) -> None: parser.add_argument( "--name", default=None, help="Server name (overrides the config's name)." ) + parser.add_argument( + "--http", + action="store_true", + help="Serve over Streamable HTTP instead of stdio (a remote MCP server). " + "OAuth/host/port come from the config's 'auth'/'host'/'port' keys.", + ) + parser.add_argument( + "--host", default=None, help="HTTP bind host (with --http; default 127.0.0.1)." + ) + parser.add_argument( + "--port", type=int, default=None, help="HTTP bind port (with --http; default 8000)." + ) args = parser.parse_args(argv) try: refs, name = resolve_server_config( @@ -120,7 +133,23 @@ def main(argv: Optional[list[str]] = None) -> None: ) except (ValueError, OSError) as e: parser.error(str(e)) - serve_stdio(refs, name=name) + + if args.http: + from py2mcp.http import serve_http + + cfg = load_server_config(args.config) if args.config else {} + try: + serve_http( + refs, + name=name, + host=args.host or cfg.get("host") or "127.0.0.1", + port=args.port or cfg.get("port") or 8000, + auth=cfg.get("auth"), + ) + except (ValueError, OSError) as e: + parser.error(str(e)) + else: + serve_stdio(refs, name=name) if __name__ == "__main__": diff --git a/tests/test_http.py b/tests/test_http.py new file mode 100644 index 0000000..b13c4e7 --- /dev/null +++ b/tests/test_http.py @@ -0,0 +1,123 @@ +"""Tests for the remote (Streamable-HTTP + OAuth) serving layer. + +All offline: building the resource-server auth provider and the ASGI app performs +no network I/O (JWKS fetching is lazy, on the first request), so these construct +real fastmcp objects without standing up a server or contacting an IdP. The +blocking ``serve_http`` is exercised only via a monkeypatched CLI (no port bind). +""" + +import json + +import pytest + +from py2mcp.http import mk_auth_provider, mk_http_app + +# A complete, fake JWT resource-server config (no network at construction). +_AUTH = { + "type": "jwt", + "jwks_uri": "https://idp.example.com/.well-known/jwks.json", + "issuer": "https://idp.example.com", + "audience": "https://conn.example.com/mcp", + "authorization_servers": ["https://idp.example.com"], + "base_url": "https://conn.example.com", + "required_scopes": ["mcp:read"], +} + + +# --- mk_auth_provider -------------------------------------------------------- + + +def test_no_auth_returns_none(): + assert mk_auth_provider(None) is None + assert mk_auth_provider({}) is None # falsy dict = no auth + + +def test_jwt_auth_builds_resource_server(): + from fastmcp.server.auth import RemoteAuthProvider + + provider = mk_auth_provider(_AUTH) + assert isinstance(provider, RemoteAuthProvider) + + +def test_issuer_derives_authorization_servers(): + auth = {k: v for k, v in _AUTH.items() if k != "authorization_servers"} + provider = mk_auth_provider(auth) # issuer alone is enough + assert provider is not None + + +def test_missing_signing_key_raises(): + auth = {k: v for k, v in _AUTH.items() if k not in ("jwks_uri", "public_key")} + with pytest.raises(ValueError, match="jwks_uri"): + mk_auth_provider(auth) + + +def test_missing_base_url_raises(): + auth = {k: v for k, v in _AUTH.items() if k != "base_url"} + with pytest.raises(ValueError, match="base_url"): + mk_auth_provider(auth) + + +def test_missing_authorization_servers_raises(): + auth = { + k: v + for k, v in _AUTH.items() + if k not in ("authorization_servers", "issuer") + } + with pytest.raises(ValueError, match="authorization_servers"): + mk_auth_provider(auth) + + +def test_unknown_auth_type_raises(): + with pytest.raises(ValueError, match="Unsupported auth type"): + mk_auth_provider({"type": "magic"}) + + +def test_non_dict_auth_raises(): + with pytest.raises(ValueError, match="must be a dict"): + mk_auth_provider("oauth") + + +# --- mk_http_app ------------------------------------------------------------- + + +def test_http_app_without_auth_is_asgi_app(): + app = mk_http_app(["os.path:basename"], name="conn") + # a Starlette ASGI app: callable, with routes + assert callable(app) + assert hasattr(app, "routes") + + +def test_http_app_with_auth_builds(): + app = mk_http_app(["os.path:basename"], name="conn", auth=_AUTH) + assert callable(app) + + +def test_http_app_propagates_bad_auth(): + with pytest.raises(ValueError): + mk_http_app(["os.path:basename"], name="conn", auth={"type": "nope"}) + + +# --- CLI --http wiring (no port bind) ---------------------------------------- + + +def test_cli_http_passes_auth_host_port(tmp_path, monkeypatch): + from py2mcp import http as http_mod + from py2mcp.serve import main + + cfg = tmp_path / "c.json" + cfg.write_text( + json.dumps({"name": "Conn", "refs": ["os.path:basename"], "auth": _AUTH, "port": 9001}) + ) + captured = {} + + def fake_serve_http(refs, **kwargs): + captured["refs"] = list(refs) + captured.update(kwargs) + + monkeypatch.setattr(http_mod, "serve_http", fake_serve_http) + main(["--http", "--config", str(cfg)]) + assert captured["refs"] == ["os.path:basename"] + assert captured["name"] == "Conn" + assert captured["auth"] == _AUTH + assert captured["port"] == 9001 + assert captured["host"] == "127.0.0.1"