Metadata-Version: 2.4
Name: closedmcp
Version: 0.1.1
Summary: ClosedMCP sign-in for Python MCP servers
License-Expression: Apache-2.0
Project-URL: Homepage, https://github.com/canpainc/closedmcp-sdk-python
Project-URL: Repository, https://github.com/canpainc/closedmcp-sdk-python
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Requires-Dist: mcp<2,>=1
Requires-Dist: PyJWT[crypto]<3,>=2.8
Requires-Dist: uvicorn<1,>=0.30
Requires-Dist: starlette<1,>=0.27
Provides-Extra: fastmcp
Requires-Dist: fastmcp<3,>=2; extra == "fastmcp"
Provides-Extra: dev
Requires-Dist: fastmcp<3,>=2; extra == "dev"
Requires-Dist: pytest<10,>=8; extra == "dev"
Requires-Dist: httpx<1,>=0.27; extra == "dev"
Requires-Dist: ruff<1,>=0.6; extra == "dev"
Provides-Extra: test
Requires-Dist: fastmcp<3,>=2; extra == "test"
Requires-Dist: pytest<10,>=8; extra == "test"
Requires-Dist: httpx<1,>=0.27; extra == "test"
Dynamic: license-file

# closedmcp

A small Python wrapper for Log in with ClosedMCP. It protects Streamable HTTP requests made to the official SDK’s `Server` or built-in `FastMCP`, or standalone FastMCP 2, verifies a signed pass on every call, and serves MCP protected-resource metadata.

## Add to an existing server

Keep your existing official `mcp.server.fastmcp.FastMCP` or standalone `fastmcp.FastMCP` 2 instance. Replace its HTTP serving call with:

```python
import os
from closedmcp import current_user, serve_with_closedmcp
serve_with_closedmcp(app, resource=os.environ["MCP_URL"], allowed_domains=os.environ["ALLOWED_DOMAINS"].split(","))
```

In any tool, use `current_user().email` or `current_user().org`. For an existing ASGI host, serve `closedmcp_app(app, resource=..., allowed_domains=...)` instead. Install with `pip install closedmcp` for the official SDK or `pip install 'closedmcp[fastmcp]'` for standalone FastMCP 2.

## Hello

```python
from mcp.server.fastmcp import FastMCP
from closedmcp import current_user, serve_with_closedmcp
app = FastMCP("hello")
@app.tool()
def hello() -> str:
    return f"Hello {current_user().email}"
serve_with_closedmcp(
    app,
    resource="https://example.com/mcp",
    allowed_domains=["example.com"])
```

Replace the first import with `from fastmcp import FastMCP` to use standalone FastMCP 2. Install with `pip install closedmcp` for the official SDK or `pip install 'closedmcp[fastmcp]'` for both. This repository also has a [runnable template](template/server.py): install the package locally with `pip install -e .`, then run `MCP_URL=https://example.com/mcp ALLOWED_DOMAINS=example.com python template/server.py`. Set `PORT` and `HOST` for the listening socket. `CLOSEDMCP_ORIGIN` and `CLOSEDMCP_JWKS_URL` are available for local integration tests.

`resource` is the exact public MCP URL and JWT audience. `allowed_domains` must contain at least one verified company domain. By default, the wrapper accepts passes from `https://closedmcp.com` and the legacy `https://closedmcp.canpai.io`, using each issuer's JWKS. An explicit `issuer` accepts only that issuer. The wrapper checks the EdDSA signature, issuer, exact audience, expiry, five-minute maximum lifetime, required identity claims, and company domain. Unknown key IDs trigger at most one JWKS refresh per 30 seconds. `current_user()` returns a `Person` with `id`, `email`, `company`, `org`, and optional `aiApp`; it raises outside a verified request. `aiApp` is a client supplied display value.

For `/mcp`, metadata is at `/.well-known/oauth-protected-resource/mcp` and the generic metadata path. A missing or invalid pass receives `401` with `WWW-Authenticate: Bearer resource_metadata="<metadata URL>"`. Authenticated non-POST requests receive `405`. To use another ASGI server, call `closedmcp_app(app, resource=..., allowed_domains=...)` and serve the returned ASGI app. Its lifespan must be run by the ASGI server.

See the [ClosedMCP documentation](https://closedmcp.com/docs).
