Metadata-Version: 2.4
Name: delegus-mcp
Version: 0.3.1
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.3.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": …}]`;
- `tool_map`: the tool map (a parsed JSON object), so a call's arguments
  become what is checked (see below).

## Checking the arguments (tool map)

By default a permission names the tool: an agent may call `push_branch`, or
not. With a tool map, it can name what the call does: `push_branch` to
`feature/*` only, or `payment.send` to Acme up to $25,000.

```python
TOOL_MAP = {"version": 1, "tools": {
    "push_branch": {"action": "api:call", "resource": "refs/heads/{arg:/branch}"},
    "payment.send": {"action": "commerce:purchase", "resource": "payee:{arg:/recipient}",
                     "amount": {"value": "/amount_cents", "currency": "/currency", "unit": "minor"}},
}}
app = DelegusMCPMiddleware(server_app, api_key=…, public_base_url=…, tool_map=TOOL_MAP)
```

- `{arg:/branch}` is a JSON pointer into the call's `arguments`. A tool
  without an entry is checked as before, by name only.
- The map is checked when the middleware starts; a mistake stops it, naming
  the tool and the field.
- It is published beside the endpoint at `<path>/delegus-tools.json`, with its
  hash, and named in the `Delegus-Tools` header on a refusal, so agents build
  the same request they sign. An agent that ignores it is refused with
  `PROOF_ACTION_MISMATCH` and told where the map is.
- Arguments that cannot be turned into what the map describes (missing, the
  wrong type, a path that climbs with `..`, including percent-encoded and
  look-alike forms, an amount with too many decimals) are refused with `ARGUMENTS_UNMAPPABLE` before Delegus is
  asked. Nothing is ever allowed because an argument was missing.
- Only the fields the map names are checked. An argument it does not name
  (a `force` flag) is not part of the permission.
- Several tools that map to the same purchase shape share one permission: a
  permission to pay Acme covers every tool that pays Acme.
- Write permission patterns in NFC Unicode; `café` typed two ways is two
  different names.

## 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 or
    keys that differ only by case (`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.
- **Arguments are checked only through a tool map.** Without one, a
  permission names the tool, not its arguments. With one, only the fields
  the map names are checked; validate everything else 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.


## The agent side: `mcp_headers`

For an agent written in Python (an Anthropic SDK loop that calls MCP tools),
`delegus_mcp.mcp_headers` signs the two request headers for one `tools/call`,
the counterpart of the Node SDK's `Agent.mcpHeaders`:

```python
from delegus_core.memory import MemorySigner
from delegus_mcp import mcp_headers

signer = MemorySigner(agent_did, agent_private_key)   # the agent's Ed25519 key
headers = mcp_headers(signer.sign, agent_did, grant_jws,
                      endpoint="https://tools.example.com/mcp",   # the gateway's --endpoint
                      tool="create_ticket", rp_did=relying_party_did,
                      args={"queue": "it-helpdesk"}, tool_map=tool_map)  # tool_map optional
# headers == {"Delegus-Grant": grant_jws, "Delegus-Proof": "<compact JWS>"}
```

The Proof binds the tool URI `<endpoint>/tools/<name>` (POST); with the
endpoint's tool map the Action binds the arguments the map names
(`mcp_action_for` shows what will be checked), and an argument the map
cannot apply raises `ArgumentsUnmappable` before anything is signed, as the
server would refuse it. The wrong key for the Grant raises `ValueError`.
