Metadata-Version: 2.4
Name: delegus-mcp
Version: 0.1.0
Summary: Delegus for Python MCP servers: ASGI middleware that checks every MCP tools/call against the caller's delegated authority before it runs (MCPServer streamable HTTP, Starlette, FastAPI).
License-Expression: Apache-2.0
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: delegus-core>=0.1.0
Requires-Dist: httpx>=0.24
Provides-Extra: test
Requires-Dist: mcp>=2; extra == "test"
Requires-Dist: uvicorn; extra == "test"
Dynamic: license-file

# delegus-mcp

Delegus for Python MCP servers. An ASGI middleware checks every MCP
`tools/call` against the calling agent's delegated authority before your tool
runs. It works with the official Python MCP SDK's `MCPServer` over streamable
HTTP, with Starlette, and with FastAPI. It follows the same rules as the Node
middleware, `delegus.mcp()` in `@delegus/sdk`.

```
pip install delegus-mcp
```

```python
import os
from mcp.server.mcpserver import MCPServer
from delegus_mcp import DelegusMCPMiddleware

mcp = MCPServer("tickets")

@mcp.tool()
def create_ticket(title: str) -> str:
    return f"created ticket: {title}"

app = DelegusMCPMiddleware(
    mcp.streamable_http_app(),
    api_key=os.environ["DELEGUS_RP_KEY"],
    public_base_url="https://tools.example.com",
)
```

Run it with any ASGI server, for example `uvicorn server:app`.
`public_base_url` is the address agents call and sign for; behind a proxy,
it's the public one. For FastAPI or Starlette, wrap the app the same way, or
use `app.add_middleware(DelegusMCPMiddleware, api_key=…, public_base_url=…)`.

Options:
- `tools`: a list or a predicate, to enforce only some tools (others pass
  unchecked);
- `relying_party`: skips a lookup when credentials are missing;
- `on_decision`: called with each decision, for logs;
- `max_body_bytes`: default 1 MiB; a larger body is refused;
- `api_base_url`;
- `timeout_seconds`.

## What happens to each request

- **`tools/call`**: the agent's `Delegus-Grant` and `Delegus-Proof` headers are
  checked, including that the Proof was signed for this tool at this address.
  - On ALLOW, the tool runs. The receipt is in `scope["state"]["delegus"]`
    (in Starlette, `request.state.delegus`) and the response carries
    `Delegus-Receipt-Id`.
  - On DENY, the middleware answers HTTP 403 with JSON-RPC error `-32040` and
    the Delegus reason, and the tool never runs.
- **Anything it can't read is refused:**
  - a missing, unparseable or oversized body, or one with duplicate keys
    (`REQUEST_UNREADABLE`);
  - a JSON-RPC batch carrying a checked tool call (`BATCH_NOT_SUPPORTED`);
  - a tool call without a string tool name (`TOOL_NAME_INVALID`).

  These are the middleware's own refusals and carry no signed receipt.
- **Delegus can't be reached:** 403 `SERVICE_UNAVAILABLE`. It fails closed.
- **Everything that can't run a tool passes through:** `initialize`,
  `tools/list`, `ping`, notifications, JSON-RPC responses, `GET` (the SSE
  stream), `HEAD`, `OPTIONS`, and a `DELETE` that ends a session.
- **The app receives exactly the bytes that were checked.**

## Limits

- **No stdio.** It checks HTTP requests, so it covers MCP servers served
  over HTTP, not stdio-only servers.
- **No argument inspection.** Delegus v0.2 binds the tool, not its
  arguments: a permission can allow `create_ticket`, not "tickets under a
  priority". Validate arguments in the tool.
- **Server-to-client messages aren't checked.** Requests your server sends to
  the client on an SSE stream (sampling, elicitation) pass as they are.
- **Only requests that reach this app are checked.** If the same tools are
  exposed some other way, those routes aren't protected.

## Tested against

The shared MCP request-shape suite in `@delegus/conformance`: the same rows
the Node middleware and the Delegus MCP gateway run. It asserts that a refused
request never reaches the tool, and that a forwarded one arrives byte for
byte. End to end, with the official Python MCP SDK (`mcp` 2.x) and a local
Delegus:
- a delegated call runs with a receipt;
- after a revoke, the same call is refused with `AUTHORITY_REVOKED`;
- a batch is refused.

Python 3.9 or later. Apache-2.0.
