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.
The guard
Section titled “The guard”Lines 20–24 of the SDK’s 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.
Wiring it up
Section titled “Wiring it up”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:
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.
High-risk tools
Section titled “High-risk tools”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.
The options
Section titled “The options”| 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.
The log line
Section titled “The log line”{ "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.
What it does not do
Section titled “What it does not do”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.
© 2026 Onbe