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 middleware for Express-style MCP servers: one line,
app., fromuse( "/ mcp", delegus. mcp( )) @delegus/.sdk - Python middleware for the official Python MCP SDK, Starlette and FastAPI:
pip install delegus-.mcp - A gateway in front of an MCP server in any other language, reached over streamable HTTP:
npx @delegus/.mcp- gateway
Node: one line#
Use
@delegus/0.2.0 or later withsdk delegus.(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.mcp( )
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#
npm install @delegus/sdkOne line#
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 verifiedOn ALLOW the receipt is on req. and the response carries Delegus-. On DENY the middleware answers HTTP 403 with error. (JSON-RPC error code -32040) and the tool never runs. When credentials are missing the response also carries Delegus- and Delegus- so a client can discover what it needs.
The options are mcp(. 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- the value on req. is the checked v0.3 response, meaning the receipt with authority opened against authority_. requires lists the prior decisions every enforced call must rest on, for v0.3 relying parties only. "v0. and "v0. pin one profile, and a receipt the setting does not accept refuses the call with SERVICE_, 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. 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_, because the server behind could read a different value than the one checked), a JSON-RPC batch that carries an enforced tools/call (BATCH_; MCP dropped batches in 2025-06-18), and a tools/call without a string tool name (TOOL_). These are the middleware's own refusals, like a missing Delegus-. 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.
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.
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>/, 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.
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/ becomes an api:call on <tool URI>/, so a Grant resource pattern of https: allows feature branches and nothing else. And payment. with { recipient: "acme", amount_ becomes a commerce: of 2,400,000 minor units on payee:acme, refused with AMOUNT_ once it passes the Grant's maxAmount. The receipt pins exactly the mapped values.
Placeholders are {arg: 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:, commerce:, commerce: and commerce:, 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., 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_, the middleware's own refusal like MISSING_ and so without a receipt, and the field is named in error. and in onDecision's error.
Agents must build the same Action, so the middleware serves the map at <path>/ (with Delegus-) and names it in a Delegus- header when credentials are missing and when an agent signed for a mapped tool without the map (PROOF_, 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( 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/, mcp/ (src/ 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.
pip install delegus-mcpimport 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:. public_ 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.. Options: tools (a list or a predicate, to enforce only some tools), tool_map (see "Arguments" below), relying_, on_decision, max_ (default 1 MiB), api_, timeout_.
On ALLOW the tool runs, the receipt is in scope["state"]["delegus"] (in Starlette, request.) and the response carries Delegus-. 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.. The agent's key is the agent.jwk file that delegus agent create - 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:
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-, 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):
export DELEGUS_API_KEY=<your org:admin key>
delegus keygen --out ./customer.jwk
delegus org create --slug <customer> --key ./customer.jwkDelegus hosts the public half; the private key stays in customer., where the customer keeps credentials. Then delegus grant create - signs a Grant for an agent.
Any language: the gateway#
@delegus/ 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.
DELEGUS_RP_KEY=dk_rp_… npx @delegus/mcp-gateway \
--upstream http://mcp:8080 --endpoint https://tools.example.com/mcp--upstream: where your MCP server listens. Keep it on a private network.--endpoint: the full MCP address agents call, path included. The agent signs every call for an exact address, and the gateway checks the call against the address it was given, so both sides take this one value; if they named different addresses, every call would be refused. A request to any other path is refused at the door (ENDPOINT_) and never reaches Delegus or your server. The olderPATH_ MISMATCH -still works and takes the origin only; a path in it is refused at startup.- public- base- url <origin> DELEGUS_: a Delegus Check verify key, read from the environment only.RP_ KEY - Optional:
--port(default 8080) and--host;--tools a,bto enforce only those tools;-(default 1 MiB);- max- body- bytes -;- api- base- url -;- no- preserve- host -(or- tool- map tools. json DELEGUS_) to bind tool arguments, see "Arguments" below.TOOL_ MAP - The gateway listens on all interfaces (
0.0.0.0) by default; pass--hostto restrict it. - A hand-written MCP client must send
initialize, thennotifications/, then send theinitialized Mcp-it got back on eachSession- Id tools/call. Agent SDKs and the agent proxy do this for you.
On ALLOW the request is forwarded with the exact bytes that were checked, plus Delegus- and Delegus- 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 -.
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:
{
"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/ is a call on …/, so a Grant whose resource is https: allows feature branches and nothing else. payment. with {"recipient": "acme", "amount_ is a purchase of 2,400,000 minor units on payee:acme, refused with AMOUNT_ above the Grant's limit. The receipt pins exactly the mapped values.
- Placeholders are
{arg:into the call's arguments. A value can never add a wildcard, climb with<JSON pointer>} ..(plain, percent-encoded or in lookalike characters) or change the tool URI;/inside a value keeps its meaning, sofeature/xlands underfeature/*. - Tools without an entry keep today's behaviour. A map the gateway cannot apply refuses to start, naming the tool and field. A call whose arguments the map cannot resolve is refused before Delegus is asked (
ARGUMENTS_, the gateway's own refusal, no receipt).UNMAPPABLE - Agents build the same Action: the gateway serves the map at
<path>/, announces it in adelegus- tools. json Delegus-header, andTools @delegus/fetches it and signs through it. An agent that ignores it on a mapped tool is refused withsdk PROOF_and told to fetch the map.ACTION_ MISMATCH - Two tools mapped to the same purchase shape share one authority: a Grant for one authorizes the other. Only the fields the map names are bound;
git.push'sforceflag is not, so validate it in the tool.
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/ 0.4.0 (mcp/ 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/ 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.
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--endpoint: the gateway's--endpoint, so the proxy signs for the same address.--agent-key: the agent's private key fromdelegus agent create.--grant: the Grant the customer signed for that agent. Both are files, never flags.- It listens on
127.. Anyone who can reach that port acts as the agent, so it warns if you bind it anywhere else.0. 0. 1: 8787
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#
- Anything they can't read is refused, never passed on: a missing, unparseable or oversized body (
REQUEST_), a JSON-RPC batch carrying a checked tool call (UNREADABLE BATCH_), a tool call without a string tool name (NOT_ SUPPORTED TOOL_). These are their own refusals and carry no signed receipt.NAME_ INVALID - Delegus can't be reached: HTTP 403
SERVICE_. They fail closed.UNAVAILABLE - Everything that can't run a tool passes through:
initialize,tools/list,ping, notifications, JSON-RPC responses,GET(the SSE stream),HEAD,OPTIONS, and aDELETEthat ends a session.
Limits#
- Only what goes through the check is protected. The gateway must be the only way in: keep the upstream on a private network (it warns at startup if the upstream looks publicly routable). With the middleware, tools exposed some other way aren't covered.
- No stdio. All three cover MCP servers reached over HTTP, not servers that only speak stdio.
- Arguments are bound only through the tool map. Without a map, Delegus Check binds the tool name only, so a Grant can allow
create_but can't limit it to tickets under a priority. With a map, the arguments it names are bound; the ones it does not name are not (aticket forceflag on a push), and a resource that has to be derived from an argument (a URL's host, an email's domain) cannot be expressed yet: design the tool so the resource is an argument (hostandpath, ordomainandlocal) and the map covers it. Validate the rest in the tool. - Server-to-client messages aren't checked. Requests the server sends to the client on an SSE stream (sampling, elicitation) pass through as they are.
- The gateway needs TLS in front of it or at it, since it can't inspect traffic it doesn't decrypt, and it adds one network hop. No latency figures are published.
What was measured#
Local runs, not production. Each ran against a local Delegus.
- Node middleware: our example MCP server runs a scripted session: a delegated
create_call is allowed and receives a signed receipt; the Grant is revoked; the same call, with a fresh Proof, is then denied withticket AUTHORITY_before the tool runs.REVOKED - Python middleware: with the official Python MCP SDK (
mcp2.x), a delegated call ran with a receipt, the same call after a revoke was refused withAUTHORITY_, and a batch was refused.REVOKED - Gateway: in front of two MCP servers with no Delegus code in them, one built with the official Python MCP SDK and one hand-written in Ruby. In each, a delegated call ran the tool once with a receipt, the same call after a revoke was refused with
AUTHORITY_, and a batch was refused; refused calls never ran the tool.REVOKED - Tool map, Node: the middleware and the gateway, against the in-process Delegus with real Grants: the same
git.pushwas allowed forfeature/xand refusedRESOURCE_forNOT_ AUTHORIZED main; the samepayment.was allowed at 2,400,000 minor units and refusedsend AMOUNT_at 2,600,000; a path withEXCEEDS_ AUTHORITY .., a string where the amount should be, and a missing currency were refused before Delegus was asked with no receipt; an agent that signed without the map was refusedPROOF_and pointed at the map; refused calls never reached the server.ACTION_ MISMATCH
All three also run the same MCP request-shape suite in @delegus/, which asserts that a refused request never reaches the tool and that a forwarded one arrives byte for byte.
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.
Read next#
- Integrate as a relying party: the same
/verifycall, outside MCP. - Receipts & offline verification: what the receipt contains and how to re-check it without calling Delegus.
- SDK and CLI reference: every export, including
mcp()andmcpHeaders(.)