MCP Server Security · Object Capabilities
MCP server object capabilities security — capability-based authorization for MCP tool access, unforgeable capability references, and capability attenuation
The object capability model (ocap) is an authorization principle where the right to perform an action is represented by a reference to an unforgeable capability object — not by checking identity against an ACL. In MCP servers, ambient authority means that any code in the server process can call any tool, reach any database, or invoke any external service — because it's all reachable via module imports and global state. Replacing ambient authority with object capabilities makes authorization a structural property of the code: you literally cannot call a tool you were never handed a capability for, because you don't have a reference to it.
Ambient authority vs. object capabilities in MCP tool dispatch
| Aspect | Ambient authority (typical MCP server) | Object capability model |
|---|---|---|
| How access is granted | Implicit — all code in the process has access to all imported modules | Explicit — code receives a capability reference; without it, access is structurally impossible |
| Authorization check location | Runtime ACL check deep in the call stack, often skipped by bugs | At construction time — the capability object only exists if authorization was granted |
| Confused deputy | Possible — any trusted intermediary can be manipulated into using its ambient authority on behalf of an attacker | Prevented — intermediaries only hold the capabilities they were explicitly given; they can't use authority they don't hold |
| Attenuation | Hard — requires rebuilding ACL checks in wrappers | Natural — wrap the capability object to remove or constrain permissions before passing it on |
| Revocation | Complex — must purge from ACL tables and invalidate caches | Drop the reference; any holder of a revocable forwarder automatically loses access |
Implementing object capabilities in a Node.js MCP server
Object capabilities in JavaScript are simply closures that encapsulate state and expose only the operations they should permit. The key discipline: the capability object is the only reference to the underlying resource — it is never exposed globally or through module exports accessible to untrusted code.
// Bad pattern: ambient authority — any module can import and call these
import { db } from './database.js';
import { emailClient } from './email.js';
export function handleToolCall(name, args) {
if (name === 'queryUsers') return db.query('SELECT * FROM users WHERE id = ?', [args.userId]);
if (name === 'sendAlert') return emailClient.send({ to: args.to, body: args.body });
}
// Good pattern: capability-based — tools only receive the capabilities they need
function createQueryCapability(db, allowedTable) {
return {
query: (userId) => db.query(
`SELECT * FROM ${allowedTable} WHERE id = ? AND deleted_at IS NULL`,
[userId]
),
// No insert, update, delete — not in this capability
};
}
function createEmailCapability(emailClient, allowedDomain) {
return {
send: (to, subject, body) => {
if (!to.endsWith(`@${allowedDomain}`)) throw new Error('CAP_EMAIL_DOMAIN_VIOLATION');
return emailClient.send({ to, subject, body });
},
};
}
// At startup: construct capabilities with their constraints baked in
const userQueryCap = createQueryCapability(db, 'users');
const internalEmailCap = createEmailCapability(emailClient, 'internal.example.com');
// Tool handler only receives the capabilities it is explicitly passed
function createToolHandler({ queryCap, emailCap }) {
return function handleToolCall(name, args) {
if (name === 'queryUser') return queryCap.query(args.userId);
if (name === 'sendAlert') return emailCap.send(args.to, args.subject, args.body);
throw new Error('UNKNOWN_TOOL');
};
}
const toolHandler = createToolHandler({
queryCap: userQueryCap,
emailCap: internalEmailCap,
});
// toolHandler has no direct access to db or emailClient — only the attenuated capabilities
Capability attenuation via membrane objects
A membrane is a capability wrapper that interposes on all operations, applying additional constraints before delegating to the underlying capability. In MCP servers, membranes are useful for tenant isolation: each tenant session receives a membrane over the base capability that constrains all operations to that tenant's data.
function createTenantMembrane(baseCap, tenantId) {
return {
query: (userId) => {
// Attenuate: enforce tenant scope on every call
return baseCap.queryWithTenant(userId, tenantId);
},
// Operations not in the membrane interface are structurally inaccessible
// baseCap.adminQuery() cannot be reached through the membrane
};
}
// Session setup: each session gets a membrane scoped to its tenant
function onSessionStart(tenantId, baseCap) {
const sessionCap = createTenantMembrane(baseCap, tenantId);
// Pass sessionCap to the tool handler; it cannot exceed tenant scope
return createToolHandler({ queryCap: sessionCap, emailCap: internalEmailCap });
}
Revocable capabilities for session invalidation
function createRevocableCapability(target) {
let alive = true;
const proxy = new Proxy(target, {
get(obj, prop) {
if (!alive) throw new Error('CAP_REVOKED');
return obj[prop];
},
apply(obj, thisArg, args) {
if (!alive) throw new Error('CAP_REVOKED');
return obj.apply(thisArg, args);
},
});
const revoke = () => { alive = false; };
return { cap: proxy, revoke };
}
// On session logout or token expiry:
const { cap: sessionCap, revoke: revokeSession } = createRevocableCapability(baseCap);
// Pass sessionCap to tool handler
// On logout:
revokeSession(); // All future calls through sessionCap throw immediately
JavaScript's native Proxy.revocable() provides built-in revocable references: const { proxy, revoke } = Proxy.revocable(target, handler);. Call revoke() and the proxy becomes permanently unusable, throwing a TypeError on any property access. Use this for session capabilities that must be invalidated on logout.
SkillAudit findings for object capability violations in MCP servers
SkillAudit's static analysis identifies ambient authority patterns — global imports used directly in tool handlers without attenuation. Run a free audit to check your MCP server's authorization architecture.