Skip to main content
Glama
i2mint

py2mcp

by i2mint
README.md
# py2mcp

Quick MCP (Model Context Protocol) server creation from Python functions.

<!-- epythet:agentic-readme:start -->
## For AI agents

`py2mcp` publishes its documentation in forms made for coding agents. If you are one, start here.

**The documentation, machine-readable**: [`llms.txt`](https://i2mint.github.io/py2mcp/llms.txt) indexes every page; [`py2mcp.md`](https://i2mint.github.io/py2mcp/py2mcp.md) is the whole documentation in one file; every page has a `.md` twin; [`objects.inv`](https://i2mint.github.io/py2mcp/objects.inv) maps symbols to URLs.

If you identify as a dinosaur, the rest of this README is written for you, starting at [Installation](#installation).
<!-- epythet:agentic-readme:end -->

## Installation

```bash
pip install py2mcp
```

## Quick Start

```python
from py2mcp import mk_mcp_server


def add(a: int, b: int) -> int:
    """Add two numbers"""
    return a + b


def greet(name: str = "world") -> str:
    """Greet someone"""
    return f"Hello, {name}!"


# Create and run MCP server
mcp = mk_mcp_server([add, greet])

if __name__ == "__main__":
    mcp.run()
```

That's it! Your functions are now available as MCP tools.

## Features

- **Simple**: Just pass functions to `mk_mcp_server()`
- **Flexible**: Supports input/output transformations
- **Pythonic**: Clean, decorator-free function definitions
- **Powerful**: Built on FastMCP for production-ready servers

## Input Transformations

Transform inputs before they reach your functions:

```python
from py2mcp import mk_mcp_server, mk_input_trans
import numpy as np


def add_arrays(a, b):
    """Add two numpy arrays"""
    return (a + b).tolist()


# Convert list inputs to numpy arrays
input_trans = mk_input_trans({"a": np.array, "b": np.array})
mcp = mk_mcp_server([add_arrays], input_trans=input_trans)
```

## From Stores (MutableMapping)

Automatically expose CRUD operations from any mapping:

```python
from py2mcp import mk_mcp_from_store

projects = {"proj1": {"name": "Project 1"}, "proj2": {"name": "Project 2"}}
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.

## 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`).

## Usage logging (who called which tool, with what, and what came back)

A deployed connector's web-server log says only that something `POST`ed `/mcp`. `UsageLogger` is a middleware that writes **one record per tool call** — time, caller (from the OAuth token), tool, arguments, outcome (`ok` / `empty` / `error`), result size, latency, connector name and version — plus one flagged record per `initialize` handshake, so "a client has it enabled" and "a client actually used it" stop looking the same:

```python
from py2mcp import mk_http_app, UsageLogger

app = mk_http_app(
    refs,
    name="snout",
    auth=AUTH,
    middleware=[UsageLogger(name="snout", version="1.4.0", redact=("api_key",))],
)
```

With no `sink=`, records go to one JSON-lines file per day under `$XDG_DATA_HOME/py2mcp/usage/<name>/` (never the app directory), pruned to 90 days. Any callable is a sink — `records.append` in a test, `mapping_sink(dol_store)` to lay records out one-per-key in a `dol` store (and so S3) — and a failing sink never fails the tool call. The arguments are your users' own questions, so logging is off unless you attach the middleware, `include_args=False` drops them, `redact=` masks named fields, `max_args_chars` caps the rest, and error text (which echoes arguments) is kept only when every argument is. Read it back with `py2mcp usage <dir> [--since YYYY-MM-DD] [--json | --records]`, or `iter_records` → `summarize` in Python. Design and prior art: `misc/docs/decisions/0001-usage-logging-is-a-py2mcp-middleware.md`.

## Instructions (the server's model-facing description)

Every builder also accepts `instructions=` — a natural-language string surfaced to
the connecting client/model as the server's [`instructions`](https://gofastmcp.com/servers/server),
attached at construction exactly like `auth=`/`middleware=`. It's the place to say
what the tools are for and the intended workflow, so a model can orient itself
without calling a tool:

```python
mcp = mk_mcp_server(
    [render, estimate],
    instructions="Turn source docs into narrated audio. Always estimate_cost before a render.",
)
# same keyword on mk_mcp_from_refs(...), mk_mcp_from_store(...), mk_http_app(...),
#                 serve_http(...), serve_stdio(...)
```

Like `middleware=`, it's a programmatic argument (not yet wired through the
`python -m py2mcp` CLI / JSON-config path).

## Prompts and resources

Every builder also accepts `prompts=` and `resources=`, so a server that ships MCP
[prompts](https://gofastmcp.com/servers/prompts) and
[resources](https://gofastmcp.com/servers/resources) alongside its tools can be
built declaratively in one call, instead of reaching past the builder to register
them by hand on the returned `FastMCP` object:

```python
def summarize_request(topic: str) -> str:
    return f"Summarize the latest on {topic}."


def schema() -> dict:
    return {"type": "object"}


mcp = mk_mcp_server(
    [render, estimate],
    prompts=summarize_request,  # a callable, or an iterable of them
    resources={"schema://analysis": schema},  # {uri: callable}
)
# same keywords on mk_mcp_from_refs(...), mk_mcp_from_store(...), mk_http_app(...),
#                  serve_http(...), serve_stdio(...)
```

`prompts` accepts a single callable or an iterable, normalized the same way `funcs`
is for tools. `resources` is a `{uri: callable}` mapping — each callable is invoked
to produce that resource's content when a client reads its URI.

## "Add to Claude" install links

Once a server is hosted, the last mile is getting a human to add it. There's no
true one-click install for an unlisted connector (listing requires Anthropic
review), but a prefilled link opens the add-connector modal with the name and
URL already filled in, so the user only has to confirm:

```python
from py2mcp import claude_install_link, markdown_install_badge

claude_install_link("snout", "https://example.com/api/snout_mcp/mcp")
# 'https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=snout&...'

markdown_install_badge("snout", "https://example.com/api/snout_mcp/mcp")
# '[Add snout to Claude](https://claude.ai/customize/connectors?...)'  <- paste into a README

claude_install_link("snout", "...", admin=True)  # org-wide page, not per-user
```

Both are pure string functions (stdlib only, no server needed). Three caveats
the link itself can't express:

- Custom connectors are a **paid-plan** feature, so the link goes nowhere for a
  Free-plan user.
- `admin=True` targets the org-wide install page — the right one when an admin
  is rolling a connector out to a workspace, the wrong one for a personal install.
- **A link is not an access grant.** If the server is an OAuth resource server
  with an allowlist (see above), someone not on it can follow the link, complete
  the flow, and still be refused. Hand out the link together with whatever adds
  them to the allowlist.

## License

MIT