Metadata-Version: 2.4
Name: delegus-mcp
Version: 0.2.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.2.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`;
- `protocol`: `"auto"` (the default), `"v0.2"` or `"v0.3"` (see below);
- `requires`: v0.3 only, the prior decisions every checked call must rest
  on, as `[{"fingerprint": …, "mode": …}]`.

## Every receipt is checked

Each relying party receives receipts in its own profile's format: v0.2
(`delegus-base-v1`) or v0.3 (`delegus-base-v3`, whose receipts carry the
matched permission as a commitment, with the permission and the key to open it
returned beside the receipt). With the default, `protocol="auto"`, the
middleware accepts either, chosen by the version the receipt was signed with,
and checks it with `delegus-core` before the tool runs:

- the signature, against Delegus's published receipt keys
  (`<api_base_url>/.well-known/did.json`, which must name `did:web:delegus.ai`
  in production), fetched once and reused;
- that the receipt is for this request: its Grant, Proof and action hashes
  match what was sent;
- that the decision, the reason and the individual checks agree;
- for v0.3, that the permission opens its commitment, and that the dependency
  results and `requires` match.

`protocol="v0.2"` or `"v0.3"` pins one format. An answer that fails any check,
or comes in the other format under a pin, is refused with
`SERVICE_UNAVAILABLE`, and `on_decision` gets an `error` field naming the
cause. There is one check per call, never a retry: a second check with the
same Proof would be refused as a replay.

## 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, or its answer doesn't check:** 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 binds the tool, not its arguments
  (v0.2 and v0.3 alike): 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, for a relying party on each profile:
- 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.
