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.
@onbe/mcp
An MCP server’s tools are an API with a different shape, not a different security problem.
@onbe/mcp is @onbe/server with the MCP SDK’s plumbing around
it: it verifies the task token at the transport, decides every tool call from the token’s scopes
and the tool’s policy, and writes one line per call naming the human and the agent.
The package is @onbe/mcp, published from a release tag. @modelcontextprotocol/sdk (1.20 or
later, below 2) is a peer dependency: it is not pulled in for you. @onbe/server is. Node 20 or
later, ESM only.
Protect an MCP server is the narrative version.
OnbeGuard
Section titled “OnbeGuard”One guard per server.
const guard = new OnbeGuard({ issuer: 'https://onbe.internal.example.com', audience: 'https://jira.internal', tools: { search: { scope: 'jira:read' }, comment: { scope: 'jira:comment', highRisk: true }, },});
guard.protect(server);| Option | Type | Default | What it is |
|---|---|---|---|
issuer |
string |
— | The control plane’s issuer URL |
audience |
string |
— | What this server is |
tools |
Record<string, ToolPolicy> |
— | Every tool and what it requires |
requireActor |
boolean |
true |
Refuse tokens with no act |
maxDelegationDepth |
number |
1 |
Longest act chain accepted |
clockSkewSeconds |
number |
60 |
Skew tolerated |
timeoutMs |
number |
10000 |
Ceiling on a call to the control plane |
realm |
string |
the audience | Named in WWW-Authenticate |
log |
(event: CallEvent) => void |
one JSON line on stderr | Where every call is recorded |
The default log goes to stderr, not stdout, because an MCP server on a stdio transport is talking protocol on stdout. A log line there would corrupt the session.
A ToolPolicy is { scope, highRisk? } — scope a string or an array, every one of which must
be present. A tool with no policy is refused: unknown_tool, 403. Listing it is how it becomes
callable. A tool listed with no scope is a configuration mistake, and the guard throws at
construction rather than serve it.
Methods
Section titled “Methods”| Method | Returns | What it does |
|---|---|---|
protect(server) |
the server | Puts the guard in front of every tool call it will ever dispatch |
decide(tool, authInfo) |
Promise<OnbeAuthError | undefined> |
The decision for one call, logged either way |
verifyAccessToken(token) |
Promise<AuthInfo> |
The MCP SDK’s OAuthTokenVerifier, for its bearer middleware |
verify(token) |
Promise<AuthInfo> |
The same check, refusing with an OnbeAuthError |
authenticate(authorization) |
Promise<AuthInfo> |
The same, from an Authorization header |
reject(response, error) |
undefined |
Answers a refused request on a Node response |
protect fails closed in two ways worth knowing about: it throws if it cannot see the server’s
request handlers, and it throws if any tool is already registered. Call it before registering
tools, and no tool can slip in unguarded.
verify settles the actor rules at the transport — a token with no act, or a chain too deep,
never reaches a tool. AuthInfo.clientId is the token’s client_id, else act.sub.
The verified claims travel in AuthInfo.extra.onbe as a TaskToken.
What a call costs
Section titled “What a call costs”One round trip per call, never two, and never fewer. A token carrying introspect_required is
introspected as it comes in at the transport; that answer is good for exactly one tool call, the
one the transport authenticated, and every later call on the same authentication is introspected
again at the tool. A tool marked highRisk on a token without that claim is introspected at the
tool whatever the transport did.
The log line
Section titled “The log line”{ "event": "tool.call", "at": "2026-09-12T14:31:05.412Z", "tool": "comment", "decision": "deny", "reason": "not_active", "sub": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "act": "agent:jira-triage", "depth": 1, "task_id": "task_01HQZX9K4M", "jti": "tok_01HQZX9K5P"}This is the record of what an agent actually did with a token, and it lives here rather than in
the control plane’s ledger: the control plane records that a token was issued, not what it was
used for. tool.called is reserved in the contract and not produced by anything, so this line is
the whole story — see the audit ledger.
Refusals
Section titled “Refusals”The same OnbeAuthError and the same reasons as
@onbe/server, plus unknown_tool for a tool with no
policy. Through verifyAccessToken they are rethrown as the MCP SDK’s own error types, so the
SDK’s middleware answers 401, 403, or 500 for a 503 this side could not resolve, with the
reason; through decide a refusal comes back as a tool result whose text names it, unless the
call was task-augmented, where it is a protocol error instead.
OnbeMcpError is the old name of OnbeAuthError, kept as an alias.
© 2026 Onbe