Skip to content

Pre-release. v0.1 is not out yet, so there is nothing to install and no public source to clone — the quickstart builds from a checkout.

Protect an MCP server

An MCP server hands tools to a model. Without something in front of it, any process that can reach it can call them, and the log says nothing about who wanted the call made.

@onbe/mcp puts the same contract as a tool server around MCP: every call carries a task token, every tool names the scope it needs, and every call is logged with the human and the agent.

Lines 20–24 of the SDK’s examples/mcp-jira/index.ts:

examples/mcp-jira/index.ts
const guard = new OnbeGuard({
issuer: process.env['ONBE_ISSUER'] ?? 'http://127.0.0.1:5100',
audience: 'https://jira.internal',
tools: { search: { scope: 'jira:read' }, comment: { scope: 'jira:comment', highRisk: true } },
});

tools is the whole policy, and it is an allowlist: a tool not listed here is refused. Adding a tool to the server without adding it to this map does not quietly expose it; it makes it uncallable until somebody decides what it needs.

server.ts
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
createServer((req, res) => serve(req, res).catch((e) => guard.reject(res, e))).listen(port());
async function serve(req: IncomingMessage, res: ServerResponse): Promise<void> {
const auth = await guard.authenticate(req.headers.authorization);
await (await jira()).handleRequest(Object.assign(req, { auth }), res);
}

Two calls do the work. guard.authenticate verifies the token at the transport, before any MCP machinery runs — a request with no token never reaches the protocol layer. guard.reject turns a failure into the right HTTP response, including the WWW-Authenticate header. On Express with the MCP SDK’s own middleware, requireBearerAuth({ verifier: guard }) does what authenticate and reject do here. @modelcontextprotocol/sdk is a peer dependency: install it beside @onbe/mcp.

Then wrap the MCP server itself so each tool call is checked against its policy.

Lines 33–40 of the SDK’s examples/mcp-jira/index.ts:

examples/mcp-jira/index.ts
const server = guard.protect(new McpServer({ name: 'jira', version: '0.1.0' }));
server.registerTool(
'search',
{ description: 'Find issues by text.', inputSchema: { query: z.string() } },
async ({ query }) => ({
content: [{ type: 'text', text: `PROJ-1: "${query}" reported by a user` }],
}),
);

protect returns the same server with every registered tool behind the guard. You register tools exactly as you would have. Call it before registering any tool; it throws if one is already there, so no tool can slip in unguarded.

highRisk: true makes the guard introspect at the control plane on every call to that tool rather than trusting the local validity check until the token expires.

The rule of thumb is the same as for a tool server: if you would be uncomfortable with the call happening five minutes after you hit the kill switch, it is high-risk. search is not. comment is — it writes something with a person’s name on it.

An operator can decide it for a whole audience instead: a token for an audience listed in the agent’s high_risk_audiences carries introspect_required, and the guard honours it without any tool being marked. It costs one round trip per call, not two. The guard introspects such a token as it comes in at the transport, and the first tool call of that authentication uses that answer; every later call on the same authentication is introspected again at the tool, so a long session never coasts on a stale answer.

Option Default What it does
issuer The control plane’s issuer URL
audience What this server is; the aud a token must carry
tools Every tool and what it requires. Unlisted tools are refused
requireActor true Refuse tokens with no act claim, so only agents get in
maxDelegationDepth 1 Longest act chain accepted
clockSkewSeconds 60 Tolerance on exp, nbf and iat
timeoutMs 10000 Longest a call to the control plane may take
realm the audience Named in WWW-Authenticate
log one JSON line on stderr Where every call is recorded

The default log goes to stderr rather than stdout, because an MCP server on a stdio transport is speaking protocol on stdout. Logging to stdout there corrupts the session.

{
"event": "tool.call",
"at": "2026-09-09T14:04:07.221Z",
"tool": "comment",
"decision": "allow",
"sub": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"act": "agent:jira-triage",
"depth": 1,
"task_id": "task_01HQZX9K4M",
"jti": "tok_01HQZX9K5P"
}

sub is the human. That is the field that turns “an MCP server got called” into “this person’s agent commented on this ticket”, and it is why the guard is worth having even on a server whose tools are all read-only.

It does not decide whether a particular issue may be commented on. Onbe’s policy is scopes, audiences, lifetimes and depth; whether this ticket is one this user may touch is your application’s question, and the claims are right there in the handler to answer it with.

OnbePre-release. v0.1 is not out yet.

© 2026 Onbe