@apoa/mcp
v0.1.4
Published
APOA authorization for MCP servers — per-tool-call scoping, delegation chains, audit trails
Maintainers
Readme
@apoa/mcp
APOA authorization for MCP servers. Per-tool-call scoping, delegation chains, audit trails.
MCP has OAuth 2.1 for connection-level auth (who can connect). This package adds action-level auth (what they can do once connected).
MCP Client --> @apoa/mcp --> MCP Server
(authorize every tool call)Two Modes
Middleware (for servers you build)
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { withAPOA } from '@apoa/mcp';
const server = new Server({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } });
withAPOA(server, {
key: publicKey,
mappings: {
read_file: 'filesystem:files:read',
write_file: 'filesystem:files:write',
list_dir: 'filesystem:dirs:list',
},
});Three lines. Every tool call is authorized against an APOA token passed in _meta.apoa_token. No token, no access.
Proxy (for third-party servers you can't modify)
npx apoa-mcp --config gateway.config.jsonSits between client and server, intercepts every tools/call, authorizes, strips the token, forwards.
How It Works
- MCP client passes an APOA token in
_meta.apoa_tokenon every tool call - The tool name is mapped to an APOA
service:scopepair - The token is verified (signature, expiration, revocation, replay)
- The SDK's
authorize()checks scope, constraints, and rules - If authorized, the tool runs. If not, the client gets a denial with a reason.
The token is stripped before reaching the tool handler -- the tool never sees it.
Passing Tokens
MCP clients include an APOA token in the _meta field on each tool call:
import { APOA, generateKeyPair } from '@apoa/core';
const keys = await generateKeyPair();
const apoa = new APOA({ privateKey: keys.privateKey });
const grant = await apoa.tokens.createGrant({
principal: 'did:apoa:alice',
agent: 'did:apoa:research-agent',
service: 'filesystem',
scopes: ['files:read'],
expiresIn: '1h',
});Pass grant.raw as _meta.apoa_token:
{
"method": "tools/call",
"params": {
"name": "read_file",
"arguments": {
"path": "/tmp/test.txt",
"_meta": {
"apoa_token": "<grant.raw>"
}
}
}
}On the MCP server side, configure withAPOA() with the matching public key:
withAPOA(server, {
key: keys.publicKey,
mappings: {
read_file: 'filesystem:files:read',
},
});Tool Mappings
Simple format (one string per tool):
withAPOA(server, {
key: publicKey,
mappings: {
read_file: 'filesystem:files:read',
write_file: 'filesystem:files:write',
},
});Auto-mapping (no config needed):
withAPOA(server, { key: publicKey });
// read_file -> read_file:call, write_file -> write_file:call, etc.Conditional mappings (argument-aware):
{
"toolMappings": [
{ "tool": "write_file", "service": "filesystem", "scope": "files:write:sandbox",
"when": { "path": { "startsWith": "/tmp" } }, "priority": 10 },
{ "tool": "write_file", "service": "filesystem", "scope": "files:write" }
]
}apoa.check (Dry-Run Tool)
Enable it to inject a debugging tool that checks authorization without executing:
withAPOA(server, { key: publicKey, enableCheckTool: true });Agents or humans can call apoa.check({ tool: 'write_file', path: '/home/data' }) to see whether the action would be authorized, what scope it maps to, and which rule would fire.
What This Adds to MCP
| Capability | MCP Native | @apoa/mcp | |---|---|---| | Connection-level auth (OAuth 2.1) | Yes | N/A (complementary) | | Per-tool-call authorization | No (SEP-1880 closed as NOT_PLANNED) | Yes | | Delegation chains with attenuation | No | Yes (via @apoa/core) | | Hard/soft rules engine | No | Yes | | Constraint checking per action | No | Yes | | Cascade revocation | No | Yes | | Audit trail | No (roadmap "pre-RFC") | Yes (hash-chained, tamper-evident) | | Replay protection | No | Yes (JTI-based) |
Persistent Stores
For production, use SQLite stores instead of the default in-memory stores:
import { SqliteRevocationStore, SqliteAuditStore, SqliteReplayStore } from '@apoa/mcp';
withAPOA(server, {
key: publicKey,
revocationStore: new SqliteRevocationStore('./auth.db'),
auditStore: new SqliteAuditStore('./auth.db'),
replayStore: new SqliteReplayStore('./auth.db'),
});The audit store uses SHA-256 hash chaining for tamper evidence.
Development
pnpm install
pnpm test # Run tests
pnpm run typecheck # Type check
pnpm run build # BuildLicense
Apache-2.0
Part of the APOA Standard
- APOA Spec
- @apoa/core (TypeScript SDK)
- apoa (Python SDK)

