MCP Security · Authorization
MCP server capability delegation: UCAN and macaroon-style fine-grained tool authorization
Traditional OAuth scopes grant access to broad capability classes ("read:files", "net:fetch") for the lifetime of a session. In MCP servers running inside LLM agents, this means the agent holds ambient authority over everything in those classes for the entire conversation. Capability delegation lets the server issue narrower, time-limited, attenuated authorizations that expire after one task — preventing an over-privileged agent from being abused by a prompt injection attack mid-session.
The ambient authority problem
When a user authorizes an MCP server with read:files write:files net:fetch scopes, every tool call in that session inherits the full scope set. If the user then pastes in a document that contains a prompt injection attack, the injected instructions have access to the same full scope — including net:fetch to exfiltrate data and write:files to persist a backdoor.
The user intended to use those scopes for their own task. The ambient authority model means an attacker who can inject instructions inherits that intent. Capability delegation breaks this by requiring each delegated action to be explicitly authorized at the time of delegation, not just at session start.
Ambient authority is the root cause of most prompt injection attacks that result in real damage. The injection doesn't need to bypass authentication — it just needs to redirect the already-authenticated session toward an unintended goal. See the related post on ambient authority in MCP servers.
Capability tokens: the UCAN pattern
UCAN (User-Controlled Authorization Networks) tokens encode a set of capabilities — specific actions on specific resources — that can be further attenuated (narrowed) when delegated to a sub-agent. Unlike JWTs which encode identity, UCANs encode what the holder is allowed to do:
// UCAN-style capability token (simplified — not full UCAN spec)
interface Capability {
tool: string; // which MCP tool
constraints?: { // optional argument constraints
path?: string; // e.g., "path must start with /workspace/"
max_bytes?: number;
allowed_hosts?: string[];
};
expiresAt?: number; // unix timestamp — single-task use
oneTime?: boolean; // consume on first use
}
interface DelegatedToken {
issuedBy: string; // original session's callerId
delegatedTo: string; // sub-agent receiving the capability
capabilities: Capability[];
issuedAt: number;
nonce: string; // prevents replay
signature: string; // HMAC over the above fields using session's secret
}
// Server issues a task-scoped token for a specific sub-task
function issueDelegatedToken(
ctx: MCPContext,
subAgent: string,
capabilities: Capability[]
): DelegatedToken {
const token: DelegatedToken = {
issuedBy: ctx.session.callerId,
delegatedTo: subAgent,
capabilities,
issuedAt: Date.now(),
nonce: crypto.randomBytes(16).toString('hex'),
signature: '',
};
token.signature = hmac(ctx.session.secret, JSON.stringify(omit(token, 'signature')));
return token;
}
Attenuation: you can only delegate what you have
The key security property of capability delegation: attenuation. A capability token can only grant a subset of the issuer's capabilities — never more. A sub-agent that receives a token for read_file constrained to /workspace/project/ cannot re-delegate write_file or read_file on /etc/:
function attenuate(
parentToken: DelegatedToken,
newConstraints: Capability[],
subSubAgent: string
): DelegatedToken {
// Each capability in the new token must be a subset of parent capabilities
for (const newCap of newConstraints) {
const parentCap = parentToken.capabilities.find(c => c.tool === newCap.tool);
if (!parentCap) throw new Error(`Cannot delegate ${newCap.tool} — not in parent token`);
if (newCap.constraints?.path && parentCap.constraints?.path) {
if (!newCap.constraints.path.startsWith(parentCap.constraints.path)) {
throw new Error(`Cannot expand path scope beyond parent's ${parentCap.constraints.path}`);
}
}
// enforce expiry can only be earlier, not later
if (newCap.expiresAt && parentCap.expiresAt && newCap.expiresAt > parentCap.expiresAt) {
throw new Error('Cannot delegate capability with longer expiry than parent');
}
}
return issueDelegatedToken({ session: { callerId: parentToken.delegatedTo, secret: parentToken.signature } } as any, subSubAgent, newConstraints);
}
Macaroon-style caveats
Macaroons extend bearer tokens with caveats — conditions that must be true for the token to be valid. Any holder of a macaroon can add caveats to narrow the token, but not to remove them. This makes macaroons useful for the "I authorize you to use my file-read capability, but only on files in the project folder, only for the next 5 minutes, only via the summarize-tool" pattern:
// Simplified macaroon-style caveat chaining
interface Caveat {
type: 'path_prefix' | 'tool_whitelist' | 'expires_at' | 'max_calls' | 'ip_allowlist';
value: string | number | string[];
}
interface MacaroonToken {
rootHmac: string; // HMAC of identifier, issued by root authority
caveats: Caveat[]; // each narrows the authorization
caveatHmacs: string[]; // HMAC chain — each links to the previous
}
// Server-side: verify all caveats are satisfied
function verifyCaveats(token: MacaroonToken, ctx: MCPContext, toolName: string): void {
for (const caveat of token.caveats) {
switch (caveat.type) {
case 'tool_whitelist':
if (!(caveat.value as string[]).includes(toolName)) {
throw new AuthorizationError(`Tool ${toolName} not in macaroon whitelist`);
}
break;
case 'expires_at':
if (Date.now() > (caveat.value as number)) {
throw new AuthorizationError('Macaroon expired');
}
break;
case 'max_calls':
const used = ctx.session.macaroonCallCounts.get(token.rootHmac) || 0;
if (used >= (caveat.value as number)) {
throw new AuthorizationError('Macaroon call limit exhausted');
}
ctx.session.macaroonCallCounts.set(token.rootHmac, used + 1);
break;
}
}
}
Practical patterns for MCP servers
| Use case | Delegation pattern | Key constraint |
|---|---|---|
| Sub-agent reads files for summarization task | Delegate read_file with path constraint + 10-minute expiry + max 20 calls | Path prefix = /workspace/project/, oneTime=false |
| External tool receives minimal access | Delegate single tool + expires in 60s | oneTime=true — single call, then revoked |
| User delegates to automation script | Macaroon with tool_whitelist + max_calls=5 | Script can only call whitelisted tools, at most 5 times |
| Multi-agent workflow with approval gates | Staged delegation: agent-1 delegates read to agent-2; agent-2 must request new delegation for write from original issuer | Write capability requires new explicit delegation, not automatic inheritance |
SkillAudit findings for capability delegation
Run a SkillAudit to see how your server scores on the Permissions Hygiene axis. Servers with fine-grained capability delegation and explicit attenuation enforcement consistently score higher than those using broad OAuth scopes. Paste your GitHub URL →
Related: ambient authority in MCP servers · tool chaining attacks · JWT algorithm confusion attacks