DelegusDocsv0.3 rc1

Docs/MCP

Guide

MCP servers: check delegated authority on every tool call

An MCP server gets tool calls from agents it has never seen, acting for organizations it may know well. Delegus Check verifies each tools/call against the caller's Grant and Proof before the tool runs. The tool runs only on ALLOW, and the server keeps a signed Decision Receipt it can re-verify years later. MCP itself doesn't change.

The person who gives an agent its authority stays in charge of it. They decide what it may do, one revoke takes it back everywhere, and every decision comes with a signed receipt.

Where it sits#

MCP deliberately leaves the authorization server open. Okta Cross App Access (XAA) handles the enterprise side, deciding which of your own users and apps may connect to your MCP server. Delegus handles the case between organizations. When a stranger's agent calls your tool, it answers what that agent's organization actually authorized, right at the point where the call is enforced. The answer is signed, so the server can re-check it for years, and nobody has to become an authorization server.

Three ways in#

Any web-facing MCP server, in any language. All three apply the same check and the same rules.

Node: one line#

Use @delegus/sdk 0.2.0 or later with delegus.mcp() (0.1.x can't read v0.3 receipts). Versions before 0.1.3 could pass a JSON-RPC batch, a request with no parsed body, or a tool call without a string tool name to the tool unchecked; 0.1.3 and later refuse all three.

You can make a Node MCP server (anything Express-style) authorization-aware with one line. Every JSON-RPC tools/call gets verified, nothing runs without an ALLOW, and each verified call hands you a signed Decision Receipt to keep. It fails closed. initialize, tools/list, ping and notifications pass through untouched.

Install#

text
npm install @delegus/sdk

One line#

ts
import express from "express";
import { Delegus } from "@delegus/sdk";

const app = express();
app.use(express.json());

const delegus = new Delegus({ apiKey: process.env.DELEGUS_RP_KEY, publicBaseUrl: "https://tools.acme.com" });
app.use("/mcp", delegus.mcp());   // every tools/call is now verified

On ALLOW the receipt is on req.delegus.receipt and the response carries Delegus-Receipt-Id. On DENY the middleware answers HTTP 403 with error.data.delegus = { decision, reason, receipt_id, receipt_url } (JSON-RPC error code -32040) and the tool never runs. When credentials are missing the response also carries Delegus-Relying-Party and Delegus-Verify: required so a client can discover what it needs.

The options are mcp({ tools, onDecision, relyingParty, protocol, requires, map }). tools narrows enforcement to an allow-list or a predicate, and tools outside it pass through unverified. protocol goes straight to verify(). The default "auto" works against a relying party on either profile, and for one on delegus-base-v3 the value on req.delegus.receipt is the checked v0.3 response, meaning the receipt with authority opened against authority_commitment. requires lists the prior decisions every enforced call must rest on, for v0.3 relying parties only. "v0.2" and "v0.3" pin one profile, and a receipt the setting does not accept refuses the call with SERVICE_UNAVAILABLE, with the cause in onDecision's error. The middleware never retries a check.

Anything the middleware cannot read gets the same 403 and is never passed through. That covers a POST with no parsed body (mount express.json() first), a body that is not JSON or not a JSON-RPC message, a body with duplicate member names, names that differ only by case, or an integer above 2^53 − 1 (REQUEST_UNREADABLE, because the server behind could read a different value than the one checked), a JSON-RPC batch that carries an enforced tools/call (BATCH_NOT_SUPPORTED; MCP dropped batches in 2025-06-18), and a tools/call without a string tool name (TOOL_NAME_INVALID). These are the middleware's own refusals, like a missing Delegus-Grant. Delegus is never called for them, so they carry no signed receipt and receipt_id is null. A batch passes only when every message in it provably invokes no enforced tool. A raw string or byte body is parsed and enforced; hand your MCP server the same parsed body the middleware saw rather than re-parsing the raw bytes, so both read one message. GET (the SSE stream), HEAD, OPTIONS, a DELETE without a body (session end) and JSON-RPC responses pass. A body on any other verb is read like a POST.

The agent side#

The agent presents two headers on the tools/call POST, both bound to the same tool URI.

ts
const headers = await agent.mcpHeaders({ grant, endpoint: "https://tools.acme.com/mcp", tool: "get_customer", rpDid });
// { "Delegus-Grant": "eyJ…", "Delegus-Proof": "eyJ…" }

When the server publishes a tool map (next section), the agent fetches it once and signs through it, so it builds the same Action the server checks.

ts
import { fetchToolMap } from "@delegus/sdk";
const found = await fetchToolMap("https://tools.acme.com/mcp");          // cached by hash; null when the server publishes none
const headers = await agent.mcpHeaders({ grant, endpoint: "https://tools.acme.com/mcp", tool: "git.push", rpDid,
  args: { branch: "feature/login" }, ...(found ? { map: found.map } : {}) });

What gets covered#

Without a map the middleware covers the tool. Each tools/call is one api:call on the tool URI <publicBaseUrl><path>/tools/<name>, so a Grant's resources decide which tools an agent may call, and say nothing about what it calls them with. A tool map says how a tool's arguments become the Action's resource and amount, and from then on the Grant can constrain them.

ts
app.use("/mcp", delegus.mcp({ map: {
  version: 1,
  tools: {
    "git.push":     { 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" } },
  },
} }));

With that map in place, git.push with { branch: "feature/x" } becomes an api:call on <tool URI>/refs/heads/feature/x, so a Grant resource pattern of https://tools.acme.com/mcp/tools/git.push/refs/heads/feature/* allows feature branches and nothing else. And payment.send with { recipient: "acme", amount_cents: 2400000, currency: "USD" } becomes a commerce:purchase of 2,400,000 minor units on payee:acme, refused with AMOUNT_EXCEEDS_AUTHORITY once it passes the Grant's maxAmount. The receipt pins exactly the mapped values.

Placeholders are {arg:<JSON pointer>} into the call's arguments (RFC 6901, not RFC 6570). Values are strings, or numbers and booleans in their JSON text. A value is split on / and each piece is percent-encoded with only unreserved characters kept, so a value can never add a wildcard, climb with .. (plain, percent-encoded or in lookalike characters) or change the tool URI, while a / inside a value keeps its meaning. Write resource patterns in NFC; values are not normalized.

action is api:call, in which case the resource is appended to the tool URI, or one of commerce:purchase, commerce:quote, commerce:accept and commerce:fulfil, in which case the resource is the template's result. The priced three need amount, with unit either minor (a non-negative integer) or major with decimals (a plain decimal such as "1.50", converted exactly and refused, never rounded, when it has more fraction digits than decimals). currency is a pointer or an ISO 4217 code.

Tools without an entry keep the tool-URI Action. A map the middleware cannot apply throws when the middleware is created, and ToolMapError names the tool and the field. A call whose arguments the map cannot resolve is refused before Delegus is asked, with ARGUMENTS_UNMAPPABLE, the middleware's own refusal like MISSING_CREDENTIALS and so without a receipt, and the field is named in error.data.delegus.hint and in onDecision's error.

Agents must build the same Action, so the middleware serves the map at <path>/delegus-tools.json (with Delegus-Tools-Hash) and names it in a Delegus-Tools: <url>; sha256=<hash> header when credentials are missing and when an agent signed for a mapped tool without the map (PROOF_ACTION_MISMATCH, with a hint to update the SDK or fetch the map).

One thing to keep in mind. Two tools mapped to the same commerce type with the same resource shape share one authority, so a Grant for one authorizes the other; toolMapWarnings(map) lists such pairs. And only the fields a template names are bound. A force: true on git.push is still authorized by a feature/* Grant, so validate it in the tool.

The shared vector set in @delegus/conformance, mcp/tool-map.json (src/mcp/tool-map.json in the repository), pins every rule above. The Python middleware (delegus-mcp) and the gateway apply the same map.

Python: delegus-mcp#

An ASGI middleware for the official Python MCP SDK's MCPServer over streamable HTTP, Starlette and FastAPI. delegus-mcp itself installs on Python 3.9 or later; the example below needs Python 3.10 or later, because it uses the official MCP SDK 2.x.

text
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), tool_map (see "Arguments" below), relying_party, on_decision, max_body_bytes (default 1 MiB), api_base_url, timeout_seconds.

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. The app receives exactly the bytes that were checked.

For an agent written in Python, mcp_headers signs the two headers for one tools/call, the same job as the Node SDK's Agent.mcpHeaders. The agent's key is the agent.jwk file that delegus agent create --out agent.jwk wrote. That file holds the key, not the DID (the command prints the DID once), so the agent derives its did:key from the key's public half:

python
import base64, json
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from delegus_core.did import did_key_from_ed25519, agent_kid_for
from delegus_core.memory import MemorySigner
from delegus_mcp import mcp_headers

def b64url(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

jwk = json.load(open("agent.jwk"))
agent_did = did_key_from_ed25519(b64url(jwk["x"]))           # the agent's did:key, from its public half
signer = MemorySigner(agent_kid_for(agent_did),                # MemorySigner takes the key id ...
                      Ed25519PrivateKey.from_private_bytes(b64url(jwk["d"])))  # ... and a key object

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={"title": "printer on fire"},
                      tool_map=tool_map)                           # optional: the endpoint's tool map
# {"Delegus-Grant": grant_jws, "Delegus-Proof": "<compact JWS>"}

cryptography comes with delegus-core, so there's nothing extra to install.

Pass the endpoint's tool map as tool_map= and the arguments it names are bound; an argument the map can't apply raises ArgumentsUnmappable before anything is signed, as the server would refuse it. Both need delegus-mcp 0.3.1 or later.

Sign authority for your customers, in two commands#

Signing up gives you a verify key and a sample Grant from a sandbox signer, but not a signer of your own. The shortest way to get one is in the console: let Delegus hold the key and approve each grant with a passkey (Approve grants with a passkey). To sign Grants with a key you hold, for your company or a customer, make the key and register it with your account's admin key (org:admin):

text
export DELEGUS_API_KEY=<your org:admin key>
delegus keygen --out ./customer.jwk
delegus org create --slug <customer> --key ./customer.jwk

Delegus hosts the public half; the private key stays in customer.jwk, where the customer keeps credentials. Then delegus grant create --key ./customer.jwk signs a Grant for an agent.

Any language: the gateway#

@delegus/mcp-gateway sits in front of a web-facing MCP server in any language. It is the Node middleware in front of a streaming proxy: it checks every tools/call before the server sees it and forwards only what is allowed. Node 22.18 or later.

text
DELEGUS_RP_KEY=dk_rp_… npx @delegus/mcp-gateway \
  --upstream http://mcp:8080 --endpoint https://tools.example.com/mcp

On ALLOW the request is forwarded with the exact bytes that were checked, plus Delegus-Receipt-Id and Delegus-Decision: ALLOW headers for the server's logs; a client's own copies of those two headers are always dropped, so the server can trust them. On DENY the gateway answers HTTP 403 with JSON-RPC error -32040 and the Delegus reason, and the server never sees the call. Responses, JSON or an SSE stream, are piped through as they arrive. Each decision is logged as one JSON line.

Host is preserved by default. MCP servers with DNS-rebinding protection (the official Python SDK turns it on for local hosts) check it, so allow your public host there, or pass --no-preserve-host.

Arguments#

Without a map, Delegus Check binds the tool name and nothing more. A Grant can allow git.push, but it can't say "push to feature branches only". A tool map is one JSON file that says how a tool's arguments become the Action's resource and amount. Once they're part of the Action, the Grant can limit them. Here's one:

json
{
  "version": 1,
  "tools": {
    "git.push":     { "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" } }
  }
}

With that map, git.push with {"branch": "feature/x"} is a call on …/tools/git.push/refs/heads/feature/x, so a Grant whose resource is https://tools.example.com/mcp/tools/git.push/refs/heads/feature/* allows feature branches and nothing else. payment.send with {"recipient": "acme", "amount_cents": 2400000, "currency": "USD"} is a purchase of 2,400,000 minor units on payee:acme, refused with AMOUNT_EXCEEDS_AUTHORITY above the Grant's limit. The receipt pins exactly the mapped values.

The same map works in the Node and Python middleware and the gateway (Python from delegus-mcp 0.3.1, as tool_map=), and all three reproduce the shared vector set in @delegus/conformance 0.4.0 (mcp/tool-map.json in the published package) byte for byte.

Claude Code as the agent: @delegus/agent-proxy#

Claude Code, Claude Desktop and most IDE clients read their MCP settings once, with fixed headers. A Delegus proof is new for every call, so they can't sign it themselves. @delegus/agent-proxy runs on your machine between the client and the guarded address, holds the agent's key and its Grant, and signs each tools/call. Everything else passes through unchanged.

text
npx @delegus/agent-proxy --endpoint https://tools.example.com/mcp \
  --agent-key agent.jwk --grant grant.jws
claude mcp add --transport http tools http://127.0.0.1:8787/mcp

The proxy decides nothing. The gateway asks Delegus Check, and Claude Code sees the same receipt header, or the same refusal with its reason, as any other agent. One proxy holds one agent and one Grant; run one per customer Grant.

What all three refuse, and what passes#

Limits#

What was measured#

Local runs, not production. Each ran against a local Delegus.

All three also run the same MCP request-shape suite in @delegus/conformance, which asserts that a refused request never reaches the tool and that a forwarded one arrives byte for byte.

text
1 create_ticket (delegated)              HTTP 200  ALLOW  drc_…
2 revoke
3 create_ticket after revoke             HTTP 403  DENY AUTHORITY_REVOKED  drc_…
PASS: the example MCP server verified a delegated tool call and received a receipt, and denied the revoked Grant.

Nothing here is a performance figure or a guarantee.