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.

@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.

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.

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.

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.

{
"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.

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.

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

© 2026 Onbe