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
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions py2mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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",
]
183 changes: 183 additions & 0 deletions py2mcp/http.py
Original file line number Diff line number Diff line change
@@ -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)
13 changes: 10 additions & 3 deletions py2mcp/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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))
Expand All @@ -62,21 +67,23 @@ 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')
>>> mcp.name
'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(
Expand Down
33 changes: 31 additions & 2 deletions py2mcp/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -113,14 +114,42 @@ 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(
config=args.config, refs=args.refs, name=args.name
)
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__":
Expand Down
Loading
Loading