Secure an MCP server
Put @better-iam/mcp in front of a Streamable HTTP MCP server so every tool call is authorized per tool, tools/list shows only usable tools, MCP hosts sign in with OAuth, and agents acting for people ask them to confirm risky calls.
In this guide you build a small support-ticket server that speaks the
Model Context Protocol (MCP) over Streamable HTTP, and put createMcpGate from
@better-iam/mcp in front of it. The gate decides every tools/call with the Better IAM policy engine, removes
tools the caller may not use from tools/list, and lets OAuth-capable MCP hosts (desktop assistants, IDEs) discover
your authorization server and sign the person in. You will build:
- a
ticketresource type and per-tool permission rules (tickets:read,tickets:update,tickets:delete); - an MCP endpoint at
http://localhost:4100/mcpbehind the gate, served with Hono; - callers with different access: an organization owner, a read-only service-account key, and OAuth access tokens issued to MCP hosts that registered themselves (RFC 7591);
- human confirmation for risky tools when an AI agent calls them on a person's behalf.
Prerequisites
-
The project from the Quickstart (
better-iam.config.mjs,iam.ts, a migrated database, and the organization Acme with an owner who signs in with a password), with these variables exported:export ACME_TENANT_ID=… # the tenant.id of Acme export ACME_OWNER_EMAIL=owner@acme.test export ACME_OWNER_PASSWORD='another long password' -
For the OAuth part (step 6): the provider from Protect an API with OAuth, mounted at
http://localhost:3000/oauth, with its interaction page. -
Node.js 22.12 or newer,
tsx, andcurl.
npm i better-iam hono @hono/node-server@better-iam/mcp is also exposed by the umbrella package as better-iam/mcp, which this guide uses. The gate works
with any MCP server that accepts a Web Request and returns a Response; it does not depend on an MCP SDK.
Declare what the tools act on
The gate asks the policy engine whether the caller may perform an action on a resource, so declare the
resource types and actions your tools use. ticket is one support ticket; ticket-queue is an organization's whole
queue, which search and tool listings are decided on. Both are application-owned: your resolveResource loads their
owner from your own storage.
// Stands in for your product's ticket table.
const tenantId = process.env.ACME_TENANT_ID;
export const tickets = new Map([
['T-1', { id: 'T-1', tenantId, title: 'Cannot export invoices', status: 'open' }],
['T-2', { id: 'T-2', tenantId, title: 'Typo on the pricing page', status: 'open' }],
]);import { IamError } from 'better-iam';
import { sqliteAdapter } from 'better-iam/adapter-sqlite';
import { findDocument } from './documents.mjs'; // your document lookup from the Quickstart
import { tickets } from './tickets.mjs';
const ticketActions = ['tickets:read', 'tickets:update', 'tickets:delete'];
export default {
database: sqliteAdapter({ filename: './iam.db' }),
secret: process.env.BETTER_IAM_SECRET,
baseURL: process.env.BETTER_IAM_BASE_URL,
authentication: {
sendEmail: async (message) => {
console.log(message.template, message.to, message.payload); // development only
},
},
permissions: {
resourceTypes: {
document: { actions: ['documents:read', 'documents:write'], attributes: { ownerId: 'string' } },
ticket: { description: 'A support ticket', actions: ticketActions },
'ticket-queue': { description: "An organization's ticket queue", actions: ticketActions },
},
},
resolveResource: async ({ type, id }) => {
if (type === 'ticket-queue') {
// A queue's ID is the ID of the organization that owns it.
return { type, id, tenantId: id };
}
if (type === 'ticket') {
const ticket = tickets.get(id);
if (!ticket) throw new IamError('NOT_FOUND', 'Ticket not found', 404);
return { type, id, tenantId: ticket.tenantId };
}
if (type === 'document') {
const document = await findDocument(id);
return { type, id, tenantId: document.tenantId, attributes: { ownerId: document.ownerId } };
}
throw new IamError('NOT_FOUND', 'Unknown resource type', 404);
},
};The resolver must return the type and id it was asked about and the tenant from your storage; a record of another
tenant fails the check with RESOURCE_MISMATCH. See
Application-owned and managed resources.
Create roles and a test caller
Roles say who may use which tools. Create a read-only role, give it to a service account, and issue that service account an API key to test with. The owner already holds every permission in Acme.
import { iam } from '../iam';
const tenantId = process.env.ACME_TENANT_ID!;
const signIn = await iam.api.auth.signIn({
tenantId,
email: process.env.ACME_OWNER_EMAIL!,
password: process.env.ACME_OWNER_PASSWORD!,
});
if ('mfaRequired' in signIn) throw new Error('This owner needs MFA');
const owner = { token: signIn.token };
const reader = await iam.api.roles.create(owner, {
tenantId,
name: 'Ticket reader',
permissions: ['tickets:read'],
});
await iam.api.roles.create(owner, {
tenantId,
name: 'Support',
permissions: ['tickets:read', 'tickets:update'],
});
const digest = await iam.api.serviceAccounts.create(owner, { tenantId, name: 'Ticket digest' });
await iam.api.bindings.create(owner, {
tenantId,
roleId: reader.id,
subjectType: 'identity',
subjectId: digest.id,
});
const { token: readerKey } = await iam.api.credentials.create(owner, {
tenantId,
identityId: digest.id,
name: 'mcp-test',
});
console.log({ ownerToken: signIn.token, readerKey }); // test credentials for the next stepsnpx tsx scripts/setup-mcp.ts
export OWNER_TOKEN=… # ownerToken from the output
export READER_KEY=… # readerKey from the output (a biam_key_… token)Bind the Support role to the people who handle tickets with iam.api.bindings.create (or an invitation's
roleIds).
Write the MCP handler
The handler is an ordinary Streamable HTTP MCP endpoint: it receives JSON-RPC messages over POST and answers with
JSON. It lists every tool; the gate filters the list per caller. Its second argument says who called.
import type { McpCaller } from 'better-iam/mcp';
import { tickets } from '../tickets.mjs';
export const tools = [
{
name: 'search_tickets',
description: 'List open support tickets of the organization',
inputSchema: { type: 'object', properties: { query: { type: 'string' } } },
},
{
name: 'close_ticket',
description: 'Close a support ticket',
inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
},
{
name: 'delete_ticket',
description: 'Permanently delete a support ticket',
inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
},
{
name: 'ping',
description: 'Check that the server is reachable',
inputSchema: { type: 'object', properties: {} },
},
];
type JsonRpcRequest = {
jsonrpc: '2.0';
id?: string | number | null;
method: string;
params?: Record<string, unknown>;
};
const result = (id: JsonRpcRequest['id'], value: unknown) =>
Response.json({ jsonrpc: '2.0', id, result: value });
const text = (id: JsonRpcRequest['id'], value: string, isError = false) =>
result(id, { content: [{ type: 'text', text: value }], isError });
export async function handleMcp(request: Request, caller: McpCaller): Promise<Response> {
if (request.method !== 'POST') return new Response(null, { status: 405, headers: { allow: 'POST' } });
const message = (await request.json()) as JsonRpcRequest;
if (message.id === undefined) return new Response(null, { status: 202 }); // notifications
const who = caller.kind === 'iam' ? caller.identityId : (caller.subject ?? caller.clientId);
switch (message.method) {
case 'initialize':
return result(message.id, {
// This demo accepts the client's protocol version; a production server negotiates it.
protocolVersion: String(message.params?.protocolVersion ?? '2025-06-18'),
capabilities: { tools: {} },
serverInfo: { name: 'acme-tickets', version: '1.0.0' },
});
case 'ping':
return result(message.id, {});
case 'tools/list':
return result(message.id, { tools });
case 'tools/call': {
const name = String(message.params?.name);
const args = (message.params?.arguments ?? {}) as Record<string, unknown>;
if (name === 'ping') return text(message.id, 'pong');
if (name === 'search_tickets')
return text(message.id, JSON.stringify([...tickets.values()].filter((t) => t.status === 'open')));
const ticket = tickets.get(String(args.id));
if (!ticket) return text(message.id, `No ticket ${String(args.id)}`, true);
if (name === 'close_ticket') {
ticket.status = 'closed';
return text(message.id, `Closed ${ticket.id} for ${who}`);
}
if (name === 'delete_ticket') {
tickets.delete(ticket.id);
return text(message.id, `Deleted ${ticket.id} for ${who}`);
}
return text(message.id, `Unknown tool: ${name}`, true);
}
default:
return Response.json({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'Method not found' } });
}
}Put the gate in front
createMcpGate takes the Better IAM instance, the organization the server belongs to, and one rule per tool. It
returns gate(request, next), which either answers the request itself (a challenge, a refused call) or passes it to
next with the authenticated caller.
import type { McpToolRule } from 'better-iam/mcp';
const tenantId = process.env.ACME_TENANT_ID!;
const queue = { type: 'ticket-queue', id: tenantId };
const ticketFrom = (args: Record<string, unknown>) => ({ type: 'ticket', id: String(args.id) });
export const toolRules: Record<string, McpToolRule> = {
search_tickets: { action: 'tickets:read', resource: queue, scopes: ['tickets:read'] },
close_ticket: { action: 'tickets:update', resource: ticketFrom, listAs: queue, scopes: ['tickets:write'] },
delete_ticket: { action: 'tickets:delete', resource: ticketFrom, listAs: queue, scopes: ['tickets:write'] },
ping: { public: true },
};import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { createMcpGate } from 'better-iam/mcp';
import { iam } from '../iam';
import { handleMcp } from './handler';
import { toolRules } from './rules';
const gate = createMcpGate({
iam,
tenantId: process.env.ACME_TENANT_ID!, // decisions are made in the organization that runs this server
tools: toolRules,
unlisted: 'deny', // a tool without a rule is hidden and refused (the default)
});
const app = new Hono();
app.all('/mcp', (c) => gate(c.req.raw, handleMcp));
serve({ fetch: app.fetch, port: 4100 });Each tool rule has these fields:
| Field | Type | Description |
|---|---|---|
action | string | The Better IAM action a call needs, for callers with a Better IAM credential. |
resource | { type, id } or (args) => { type, id } | The resource the call acts on: fixed, or derived from the call's arguments. Defaults to { type: 'mcp-tool', id: toolName }. |
listAs | { type, id } | For a derived resource: the resource that decides whether the tool appears in tools/list. Without it, such a tool is always listed. |
scopes | string[] | Scopes an OAuth access token needs for the tool. Without scopes, OAuth callers are refused the tool. |
public | boolean | Any authenticated caller may use the tool. |
The gate options:
| Option | Type | Default | Description |
|---|---|---|---|
iam | betterIam() instance | required | Authenticates credentials (authenticate) and decides (authorize, authorizeMany). |
tenantId | string or (caller) => string | undefined | required | The organization decisions are made in. A function picks it per caller ({ identityId, sessionTenantId }); undefined refuses the caller. |
tools | Record<string, McpToolRule> | required | The rules above, by tool name. |
unlisted | 'deny' | 'allow' | 'deny' | Tools without a rule: hidden and refused, or open to any authenticated caller. |
toolResourceType | string | 'mcp-tool' | The resource type of the default per-tool resource. |
oauth | access-token verifier | none | Also accept OAuth access tokens, decided by each tool's scopes. |
metadata | { resource, authorizationServers, scopes?, resourceName?, documentation? } | none | Serve protected resource metadata (RFC 9728) and name it in 401 challenges. |
confirmations | boolean | true when iam.api.delegations exists | Ask the person to confirm held-back actions for delegated agents. |
confirmationReason | (tool, args) => string | which tool the agent wants to run | The reason shown to the person in a confirmation request. |
maxBodyBytes | number | 4 MiB | Largest accepted JSON-RPC body; larger requests answer 413. |
Decide in the server's organization
tenantId is required because deciding in each caller's own tenant would let any organization grant itself access
by writing policies in its own tenant. Fix it to the organization that runs the server, or choose it per caller
from data you trust.
Start the server (it shares the database with your identity service):
npx tsx mcp/server.tsTest tool filtering and refusals
MCP clients send JSON-RPC over POST with Accept: application/json, text/event-stream. The gate accepts a Better
IAM credential as Authorization: Bearer … or x-api-key: a person's session token, a service-account or agent API
key, or a delegated agent session.
mcp() { curl -s http://localhost:4100/mcp -H "authorization: Bearer $1" \
-H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$2"; echo; }
# The read-only service account sees only what it may use:
mcp "$READER_KEY" '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"search_tickets",…},{"name":"ping",…}]}}
# Calling a hidden tool anyway is refused as a tool error the model can read:
mcp "$READER_KEY" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"close_ticket","arguments":{"id":"T-1"}}}'
# {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"Access denied: you may not call close_ticket"}],"isError":true}}
# The owner may use every tool:
mcp "$OWNER_TOKEN" '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"close_ticket","arguments":{"id":"T-1"}}}'
# {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"Closed T-1 for …"}],"isError":false}}How the gate treats each kind of request:
| Request | Gate answer |
|---|---|
| No credential | 401 with WWW-Authenticate: Bearer (plus resource_metadata="…" once metadata is set). |
| Invalid or expired credential | 401 with error="invalid_token". |
tools/list | Forwarded; tools the caller may not use are removed from the answer, in JSON and in event streams. Checks run 50 at a time with authorizeMany. |
tools/call the caller may make | Forwarded to next(request, caller). |
tools/call the caller may not make | A JSON-RPC result with isError: true and the text Access denied: you may not call {name}, or Unknown tool: {name}. The server never sees it. |
tools/call whose params.name is not a string | JSON-RPC error -32602, HTTP 400. |
A batch (JSON array) containing tools/call or tools/list | JSON-RPC error -32600, HTTP 400. |
| Body that is not JSON | JSON-RPC error -32700, HTTP 400. |
GET, DELETE, and other methods | Authenticated, then forwarded unchanged (the Streamable HTTP event stream and session end). |
Denied Better IAM decisions are audited by the server like any other denied decision, so a model probing tools it may not use shows up in the tenant's audit trail.
Accept OAuth tokens from MCP hosts
Desktop assistants and IDEs connect to MCP servers they have never seen, so they configure themselves: the gate's
401 names the server's protected resource metadata, the metadata names your authorization server, the host
registers itself there (dynamic client registration, RFC 7591), and the person signs in and approves it. The host
then calls the MCP server with a JWT access token whose audience is the MCP server.
First, declare the MCP server as a resource server on the provider and turn on registration. This is the provider file from Protect an API with OAuth with two additions:
import { readFileSync } from 'node:fs';
import { createOAuthProvider } from 'better-iam/oauth';
import { iam } from './iam';
const keys = JSON.parse(readFileSync(process.env.OAUTH_KEY_FILE!, 'utf8'));
export const origin = 'http://localhost:3000';
export const invoicesApi = 'http://localhost:4000/api';
export const ticketsMcp = 'http://localhost:4100/mcp';
export const issuer = createOAuthProvider({
...iam.protocolHost,
issuer: `${origin}/oauth`,
jwks: keys.jwks,
cookieKeys: keys.cookieKeys,
encryptionKey: keys.encryptionKey,
trustedOrigins: [origin],
allowInsecureLocalhost: true, // development only
resourceServers: {
[invoicesApi]: { scopes: ['invoices:read', 'invoices:write'], accessTokenTtl: 600 },
[ticketsMcp]: { scopes: ['tickets:read', 'tickets:write'] }, // added
},
// Added: MCP hosts register themselves without a token. Every registration joins Acme, may ask for
// at most these scopes, and may only get tokens for the MCP server.
registration: {
anonymous: () => ({
tenantId: process.env.ACME_TENANT_ID!,
scopes: ['openid', 'offline_access', 'tickets:read', 'tickets:write'],
resources: [ticketsMcp],
maxClients: 100,
}),
},
interactionUrl: (uid) => `${origin}/interactions/${encodeURIComponent(uid)}`,
renderDevicePage: ({ form }) => `<!doctype html><title>Device sign-in</title>${form}`,
renderLogoutPage: ({ form }) => `<!doctype html><title>Sign out</title>${form}`,
});A multi-tenant deployment maps each registration to the organization it belongs to, for example from the request's
headers.host, and returns undefined to decline. Registrations are public clients with PKCE unless the policy sets
allowConfidential, and anonymous registrations are capped per tenant by maxClients. For invited instead of open
registration, an administrator creates a registration token with issuer.createRegistrationToken; see
Turn on dynamic registration.
Then give the gate a verifier for those tokens and the metadata to publish:
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { createMcpGate } from 'better-iam/mcp';
import { createAccessTokenVerifier } from 'better-iam/oauth';
import { iam } from '../iam';
import { handleMcp } from './handler';
import { toolRules } from './rules';
const resource = 'http://localhost:4100/mcp';
const authorizationServer = 'http://localhost:3000/oauth';
const gate = createMcpGate({
iam,
tenantId: process.env.ACME_TENANT_ID!,
tools: toolRules,
oauth: createAccessTokenVerifier({ issuer: authorizationServer, audience: resource }),
metadata: {
resource,
authorizationServers: [authorizationServer],
scopes: ['tickets:read', 'tickets:write'],
resourceName: 'Acme tickets',
},
});
const app = new Hono();
app.all('/mcp', (c) => gate(c.req.raw, handleMcp));
// The gate serves the metadata document itself; route its well-known path to the gate too.
app.all('/.well-known/oauth-protected-resource/mcp', (c) => gate(c.req.raw, handleMcp));
serve({ fetch: app.fetch, port: 4100 });Restart the identity service and the MCP server. The metadata is now public:
curl -s http://localhost:4100/.well-known/oauth-protected-resource/mcp
# {"resource":"http://localhost:4100/mcp","authorization_servers":["http://localhost:3000/oauth"],
# "scopes_supported":["tickets:read","tickets:write"],"bearer_methods_supported":["header"],"resource_name":"Acme tickets"}The gate tries a bearer value as a Better IAM credential first and, when that fails, as an OAuth access token.
OAuth callers are decided only by each tool's scopes: the person's Better IAM roles are not evaluated for them, and
a token issued to another tenant than tenantId is refused. Grant tickets:write only to hosts that may change
tickets for anyone who consents, and apply your own checks in the handler with caller.subject when that is not
enough.
| Caller | caller.kind | Fields next receives | Decided by |
|---|---|---|---|
| Session, service-account or agent key, delegated session | iam | credential, tenantId, identityId, identityKind, sessionKind, agentId?, delegationId? | The policy engine: action on resource in tenantId. |
| OAuth access token | oauth | clientId, subject?, tenantId?, scopes | The tool's scopes. |
Ask a person to confirm risky calls
When an AI agent acts for a person through a delegated session (see
AI agents that act for users), the person can hold actions back for
confirmation call by call with the delegation's confirm list, for example confirm: ['tickets:delete']. The gate
handles this for you: when such an agent calls delete_ticket, the decision is denied, the gate files a confirmation
request with the person (delegations.requestConfirmation), and the agent gets a tool error telling it to retry:
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"content": [
{
"type": "text",
"text": "The person you act for must confirm this call first. A confirmation request was sent to them (it expires at 2026-11-07T10:30:00.000Z). Call delete_ticket again once they approve it."
}
],
"isError": true,
"_meta": {
"better-iam/confirmationId": "…",
"better-iam/confirmationExpiresAt": 1794047400000
}
}
}The person receives a delegation-confirmation email when the deployment sends email, and approves with
delegations.decideConfirmation. The agent's next call to the same tool on the same ticket passes, once. Word the
request with confirmationReason, or turn the behavior off with confirmations: false:
confirmationReason: (tool, args) => `Your assistant wants to run ${tool} on ticket ${String(args.id)}.`,Use the decisions inside an MCP SDK server
If your server is built with an MCP SDK and you want the checks inside its handlers instead of in front of the
transport, createMcpAuthorizer takes the same options (without metadata and maxBodyBytes) and exposes the
gate's decisions:
import { createMcpAuthorizer, type McpCaller } from 'better-iam/mcp';
import { iam } from '../iam';
import { toolRules } from './rules';
export const authorizer = createMcpAuthorizer({
iam,
tenantId: process.env.ACME_TENANT_ID!,
tools: toolRules,
});
// In your HTTP layer: undefined means no valid credential, so answer 401.
export const callerOf = (request: Request): Promise<McpCaller | undefined> => authorizer.authenticate(request);
// In your tools/call handler:
export async function assertCanCall(caller: McpCaller, name: string, args: Record<string, unknown>) {
const decision = await authorizer.canCall(caller, name, args);
// decision.reason: ALLOWED, PUBLIC, ACCESS_DENIED, UNKNOWN_TOOL, NO_RULE_FOR_CALLER,
// INSUFFICIENT_SCOPE, or CONFIRMATION_REQUESTED (with decision.confirmation.id and expiresAt)
if (!decision.allowed) throw new Error(`Refused: ${decision.reason}`);
}
// In your tools/list handler:
export const listFor = <T extends { name: string }>(caller: McpCaller, tools: T[]) =>
authorizer.visibleTools(caller, tools);Verify it works
-
Without a credential, the gate challenges and names the metadata:
curl -si -X POST http://localhost:4100/mcp -d '{}' | grep -i -E '^HTTP|www-authenticate' # HTTP/1.1 401 Unauthorized # www-authenticate: Bearer resource_metadata="http://localhost:4100/.well-known/oauth-protected-resource/mcp" -
With
READER_KEY,tools/listcontains onlysearch_ticketsandping, andclose_ticketis refused withisError: true. WithOWNER_TOKEN, all four tools are listed andclose_ticketsucceeds. -
Connect an MCP client that speaks Streamable HTTP, for example the MCP Inspector (
npx @modelcontextprotocol/inspector; its option names vary between versions), at the URLhttp://localhost:4100/mcp. Either sendREADER_KEYas a bearer token, or let an OAuth-capable client run the flow: it reads the metadata, registers athttp://localhost:3000/oauth/reg, sends you to the interaction page to sign in to Acme and approve it, and then lists the tools its scopes allow. The client must request its token withresource=http://localhost:4100/mcp; a token without it is not for the MCP server and is refused. -
The registered host appears in
issuer.listClients(owner, { tenantId })withregisteredVia: 'anonymous', and the registration is audited asiam:oauth:RegisterClient.
Troubleshooting
Next steps
Register an agent with a sponsor, delegate access, and confirm risky actions.
Dynamic registration and MCPRegistration policies, registration tokens, and the resource guard.
Resource servers and tokensAudience-restricted JWT access tokens, DPoP, and token exchange.
AI agentsSponsors, boundaries, delegations, hand-offs, and the kill switch.
Better IAM is created by Sean Filimon
Last updated
Protect an API with OAuth
Run Better IAM as the OAuth authorization server, declare a separate API as a resource server, issue audience-restricted access tokens, and verify them with scopes in a Hono or Express service.
AI agents that act for users
Register an AI agent with a sponsor and a ceiling, let a person delegate scoped and expiring access to it, have the agent act through short delegated sessions, require the person's confirmation for risky actions, and audit or stop the agent.