The gap BKey fills in MCP
The Model Context Protocol spec (2025-06-18) handles two kinds of auth:- Transport auth — OAuth 2.1 on the MCP server itself, for connecting the agent. Spec’d, mature.
- Per-tool-call consent — deliberately not spec’d. The spec says only that “there SHOULD always be a human in the loop with the ability to deny tool invocations.” In practice this means each MCP client (Claude Desktop, Claude Code, Cursor, etc.) shows its own Allow/Deny dialog when an agent calls a tool.
Who this is for
If your MCP server wraps anything with real stakes, you want this:- CI/CD —
deploy_to_production,rollback,promote_to_canary - Infra —
kubectl_delete,terraform_apply,scale_down - Finance —
send_payment,refund,transfer_funds - Admin —
delete_user,grant_role,rotate_key - Data —
drop_table,truncate,export_pii
read_logs tool, the existing Allow/Deny dialog is fine. For anything that writes, deletes, or spends — you want cryptographic consent.
The reference example
We ship a working MCP server atexamples/typescript/mcp-server that exposes a single deploy_to_production tool gated on BKey CIBA.
~/Library/Application Support/Claude/claude_desktop_config.json):
The minimal pattern
Insidesrc/index.ts, the gate is three calls:
Design rules
One scope per sensitive action. Register scopes likeapprove:deploy, approve:refund, approve:db:drop. Scopes show up on the operator’s phone and in audit logs, and they prevent a token issued for one action from being replayed against another. Don’t reuse a single “admin” scope across everything.
Always verify the token server-side. approval.approved === true tells you the CIBA flow finished. Only verifyToken() tells you the token is real, scoped correctly, and signed by BKey. Skipping verifyToken is the biggest mistake you can make — a compromised agent could feed you a fake approval result and you’d trust it.
Bind the approval to the action. Use actionDetails to show the operator exactly what they’re approving — amount, recipient, target resource, environment. If your tool takes arguments the operator cares about, surface them in the binding message and action details.
Treat jti as a nonce. Every approval token has a unique jti. Record it server-side; reject replays. For a deploy, this means one approval → one deploy. If the agent tries to reuse the same token, your server refuses.
Keep expirySeconds tight. 300s (5 min) is plenty for an operator to pull out their phone. Don’t use hours-long expiries — approvals are per-action, not per-session.
What verifyToken actually checks
From the @bkey/node README:
- Signature — EdDSA (Ed25519) against BKey’s JWKS. Algorithm pinned — no HS256 confusion, no
alg: none. - Token shape — three base64url segments, 8 KB max, no smuggled content.
- Issuer — must match
issueroption, trailing-slash tolerant. - Expiry + not-before — with configurable
clockTolerance. - Audience — if set, token’s
audmust match. - Scope — token must carry ALL scopes you required.
- Required claims —
sub,iat,exp,iss. Optional claims are type-checked if present. - Prototype pollution defense —
__proto__/constructor/prototypekeys are stripped, returned object has a null prototype.
BKeyAuthError with codes like expired_token, insufficient_scope, invalid_signature. Branch on err.code to give the agent actionable feedback.
Beyond the example
The reference example hardcodes oneBKEY_USER_DID for simplicity. For a shared MCP server:
- Resolve the DID from the tool context. If the agent is logged in as a specific user, look up their DID from your session table.
- Or do a first-use pairing. The first time a new user invokes a tool, use the device authorization flow to pair their phone, store their DID, and gate subsequent calls on it.
- Log every approval. Store
jti,sub,iat, tool name, arguments, and the deploy result. This is your audit trail — the whole point of replacing the soft UI click.
References
@bkey/nodeon npm — verification library@bkey/sdkon npm — agent client withbkey.approve()- Reference example source
- CIBA flow explainer
- Model Context Protocol spec