Agora

MCP server

Expose your governed tool registry to Claude, Cursor, and any other MCP client over Streamable HTTP — with OAuth or API-key auth, fail-closed by default, and the same role checks the agent loop applies.

Your tools are already governed: each one declares a kind, an input schema, and the roles or ability required to call it, and the ToolRegistry enforces that on every invocation. The Model Context Protocol is a standard way for an external assistant — Claude Desktop, Cursor, an internal copilot — to discover and call exactly that kind of surface.

@adonis-agora/agent/mcp_provider mounts an MCP server that reads from the same ToolRegistry the agent loop uses. Not a copy, not an export: the same singleton. A tool you write once is reachable from your /agent/chat endpoint and from an MCP client, under the same authorization, with the same input validation.

Mounting it

node ace add @adonis-agora/agent already publishes config/mcp.ts. The MCP provider, however, is not registered for you — it is opt-in, because mounting an unconfigured MCP endpoint on every app that installs the package would be surprising. Add it by hand:

adonisrc.ts
providers: [
  // ...
  () => import('@adonis-agora/agent/agent_provider'),
  () => import('@adonis-agora/agent/mcp_provider'),
]

The agent provider must come first — it owns the ToolRegistry singleton the MCP provider resolves.

Point config/mcp.ts at an auth strategy:

config/mcp.ts
import { defineMcpConfig, authKitAuth } from '@adonis-agora/agent/mcp'

export default defineMcpConfig({
  name: 'Acme Agent',
  version: '1.0.0',

  auth: authKitAuth({
    toActor: ({ accountId, scopes }) => ({
      id: accountId,
      roles: scopes.includes('admin') ? ['ADMIN'] : ['MEMBER'],
    }),
  }),
})

Point an MCP client at http://localhost:3333/mcp. It will discover the OAuth flow, log the user in, and see the tools that user's roles permit.

Configuration

McpConfig has sixteen keys — two required, fourteen optional.

KeyTypeDefaultPurpose
namestring—Server name reported in the MCP initialize handshake.
versionstring—Server version reported in the handshake.
instructionsstring—Sent in the initialize result: how the client's model should use these tools.
pathstring'mcp'Route prefix. Leading/trailing slashes are stripped, so '/mcp/' and 'mcp' are the same.
publicUrlstring—The public origin clients reach the app at (e.g. 'https://app.example.com'). Base of the RFC 9728 resource and of the WWW-Authenticate challenge. Omit → built from the request.
authMcpAuth | McpAuthFactory—How clients authenticate. Omit for open mode (see below).
actor{ id, roles?, tenantRef? }—The fixed identity for open mode.
authorizerRolesPolicyDefaultToolAuthorizer(defaultRoles)The tool gate. Pass the authz Bouncer adapter here to run MCP under abilities.
defaultRolesstring[]['ADMIN']Roles a tool requires when it declares none. Only consulted by the default authorizer.
emptyRoles'allow' | 'deny''allow'What an empty roles list means to the default authorizer. Since 0.46 it is open; 'deny' makes defaultRoles: [] or a tool's roles: [] reach nobody.
allowedToolsstring[]—Restrict which tools are listed and callable.
actions'refuse' | 'execute''refuse'action tools over MCP. 'refuse': neither listed nor callable (the loop's approval gate cannot be honoured). 'execute': they run when called — the MCP client's own confirmation stands in for it.
statelessbooleanfalseNo sessions: every POST is answered by its own transport; GET/DELETE are 405. Use it with more than one instance behind a load balancer.
describeTool(tool) => { title?, annotations? }readOnlyHint from the kindTitle and MCP annotations per tool in tools/list — clients run a read without asking and confirm a write.
endpointsMcpEndpointConfig[]—More MCP servers in the same app (see More than one endpoint).
middlewareroute middleware—Applied to the MCP endpoint (not the metadata route), before the bearer check — e.g. a rate limiter keyed on the Authorization header.

Fail-closed: no auth and no actor means 401

With neither auth nor actor configured, every request to /mcp is rejected with 401 {"error":"unauthorized: no auth configured and no fallback actor"}. That is the default an empty config/mcp.ts produces, and it is deliberate: an MCP endpoint is a remote tool-execution surface, so it never opens itself.

More than one endpoint

Each entry of endpoints is an MCP server of its own — its path, tools, auth, sessions and RFC 9728 metadata — and a protected resource of its own: a token issued for one is refused by the other. It inherits the top-level settings and overrides what it declares.

config/mcp.ts
export default defineMcpConfig({
  name: 'Acme',
  version: '1.0.0',
  path: 'mcp',
  auth: teamAuth(),
  stateless: true,
  endpoints: [
    {
      path: 'mcp/family',
      name: 'Acme (family)',
      instructions: 'The family of the signed-in person only.',
      auth: familyAuth(),
      registry: ({ app }) => app.container.make(FamilyToolRegistry),
    },
  ],
})
KeyDefault
path—Route prefix.
name, instructionstop-levelHandshake.
authtop-levelA different audience usually means a different auth.
registrythe agent's ToolRegistry(ctx) => ToolRegistry — e.g. a registry bound in the container.
allowedTools, actions, describeTooltop-level
metadataRouteNamemcp.<segments>.oauth_protected_resourceName of its metadata route (the top-level one is mcp.oauth_protected_resource).

Authentication

OAuth via authkit

authKitAuth() validates the bearer token against @adonis-agora/authkit-server and advertises the OAuth metadata MCP clients use to discover the login flow.

auth: authKitAuth({
  scopes: ['openid', 'profile', 'email', 'offline_access', 'roles'],
  resourceName: 'Acme Agent',
  toActor: async ({ accountId, scopes, clientId }) => ({
    id: accountId,
    roles: await rolesFor(accountId),
    tenantRef: await tenantFor(accountId),
  }),
}),

Turn on mcp: true in AuthKit's config and that is the whole login: claude mcp add --transport http acme https://acme.com/mcp opens the browser, the user signs in to your app and consents, and Claude Code is connected — see AuthKit → MCP clients. This server registers its URL with AuthKit at boot (publicUrl + path, or just the path on the issuer's origin when publicUrl is unset), so tokens are bound to it without the URL appearing in AuthKit's config.

The token must resolve through the authkit provider, must not be expired, and must have been issued for this server: its audience (RFC 8707 aud) must be this endpoint's URL. A token for another resource, or one with no audience (a web login's), is a 401 token was not issued for this MCP server — the MCP authorization spec forbids accepting tokens meant for someone else. audience: 'any' turns the check off, for clients that do not send resource. Anything else is a 401 carrying the reason. When the token belongs to a grant, the grant is loaded on every request too — a grant the user revoked stops its tokens immediately (401 authorization revoked). The authkit service is resolved lazily from the container on first use ('authkit.server'), so the peer only has to be bootable by the time the first MCP request arrives — not at provider boot.

Always supply `toActor`

The default toActor returns { id: accountId } — an actor with no roles. Against the default ADMIN-only authorizer that actor sees an empty tool list and cannot call anything. toActor is where your authorization model meets MCP; treat it as required in any real deployment.

What toActor receives

FieldTypeWhat it is
accountIdstringThe account the token was issued to.
scopesstring[]The token's granted scopes.
clientIdstring | undefinedThe OAuth client the token was issued to.
grantIdstring | undefinedThe grant the token belongs to.
grantAuthKitGrant | undefinedThe grant itself — the authorization the user gave that client.
activeOrg{ orgId, orgSlug?, orgRole? } | undefinedThe organization authkit bound to the grant at consent — the tenant the user authorized.
extraRecord<string, unknown> | undefinedExtra claims stored on the access token.

activeOrg is how a multi-tenant app resolves the tenant: it is what the user picked on the consent screen, not something the request can choose. Re-check it against the account's current memberships, so a user who left the org loses access on the next request:

auth: authKitAuth({
  toActor: async ({ accountId, activeOrg }) => {
    if (!activeOrg) throw new McpAuthError('the authorization is not bound to an organization')
    const membership = await membershipOf(accountId, activeOrg.orgId)
    if (!membership) throw new McpAuthError('the account is no longer a member of that organization')
    return { id: accountId, roles: [membership.role], tenantRef: activeOrg.orgId }
  },
}),

Refusing a token: McpAuthError

Throw McpAuthError from toActor (or from any strategy's verify) to refuse a token you recognized. Its message goes back in the body, with the matching challenge:

ThrownStatusWWW-Authenticate error
new McpAuthError(msg)401invalid_token
new McpAuthError(msg, { status: 403 })403insufficient_scope

The common case is a client that may log in to your app but must not reach MCP — the first-party web session, say, whose tokens carry scopes MCP clients never get:

import { authKitAuth, McpAuthError } from '@adonis-agora/agent/mcp'

auth: authKitAuth({
  toActor: ({ accountId, clientId }) => {
    if (clientId === env.get('AUTHKIT_CLIENT_ID')) {
      throw new McpAuthError('token was not issued for MCP')
    }
    return { id: accountId, roles: ['MEMBER'] }
  },
}),

Any other error thrown from toActor is still a 401 carrying its message; McpAuthError is what lets you pick the status and what stops anyOf from trying the next strategy.

Static API keys

For a trusted gateway or a machine integration, apiKeyAuth skips OAuth entirely:

auth: apiKeyAuth({
  apiKeys: [env.get('MCP_GATEWAY_KEY')],
  toActor: () => ({ id: 'gateway', roles: ['SERVICE'] }),
}),

Keys are compared in constant time over their bytes. Because this strategy exposes no OAuth metadata, the .well-known route is not mounted — clients must be configured with the key directly.

More than one kind of token: anyOf

To accept, on the same /mcp, OAuth tokens from clients that logged in through the browser and tokens for machine integrations (personal access tokens, gateway keys), combine strategies:

import { anyOf, apiKeyAuth, authKitAuth } from '@adonis-agora/agent/mcp'

auth: anyOf(
  // Your own strategy is just `{ verify }` (or a factory returning one).
  { verify: verifyPersonalAccessToken },
  authKitAuth({ toActor }),
  apiKeyAuth({ apiKeys: [env.get('MCP_GATEWAY_KEY')] }),
),

Strategies are tried in order and the first to verify the token wins. A strategy that throws a plain Error is saying "not my token", so the next one is tried; one that throws McpAuthError has claimed the token and refused it, so the chain stops there and the client gets that refusal. When none recognizes the token, the last error is the 401.

The OAuth metadata comes from the first strategy that exposes it — here authKitAuth() — so the .well-known route is mounted and every 401 still points MCP clients at the login.

Open mode

For local development, drop auth and name a fixed identity instead:

// Dev only. Every request runs as this actor, with no token check at all.
actor: { id: 'mcp-dev', roles: ['ADMIN'] },

The routes

Method & pathPurpose
POST /mcpThe Streamable HTTP endpoint: initialize, tools/list, tools/call.
GET /mcpThe SSE channel for server→client notifications on an established session.
DELETE /mcpTerminate the session and release its transport.
GET /.well-known/oauth-protected-resource/mcpRFC 9728 protected-resource metadata. Mounted only when auth exposes OAuth metadata — i.e. with authKitAuth() (alone or inside anyOf), not with apiKeyAuth() or open mode.

The well-known document is deliberately minimal and unauthenticated (and served with Access-Control-Allow-Origin: *, since MCP clients that run in a browser fetch it cross-origin):

{
  "resource": "https://app.example.com/mcp",
  "authorization_servers": ["https://app.example.com/oidc"],
  "scopes_supported": ["openid", "profile", "email", "offline_access", "roles"],
  "bearer_methods_supported": ["header"],
  "resource_name": "Acme Agent"
}

With Personal Agent Protocol on in AuthKit, the Poppy issuer it announces in Symbol.for('@adonis-agora/poppy:issuer') is added to authorization_servers too (Poppy §6).

The metadata itself is resolved per request rather than at boot, because authkit's issuer may not be readable until the app is fully booted.

The 401 challenge

Every refused request carries a WWW-Authenticate header (MCP Authorization, RFC 6750 §3, RFC 9728 §5.1). With OAuth metadata it names the document above — that is what makes an MCP client discover the authorization server and open the login in the browser:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://app.example.com/.well-known/oauth-protected-resource/mcp"

A request that carried a token which was refused adds error="invalid_token"; a 403 from McpAuthError adds error="insufficient_scope". Without OAuth metadata (apiKeyAuth() alone, or the fail-closed 401 of an unconfigured endpoint) the challenge is a bare Bearer.

Behind a proxy: publicUrl

By default resource and resource_metadata are built from the request's protocol and Host. That is wrong whenever TLS terminates at a proxy the app does not trust — AdonisJS only trusts loopback proxies by default, so behind Traefik or a load balancer the request "sees" http:// and the client is told to fetch http://…. Name the public origin instead:

config/mcp.ts
export default defineMcpConfig({
  name: 'Acme Agent',
  version: '1.0.0',
  publicUrl: env.get('APP_URL'), // e.g. 'https://app.example.com'
  auth: authKitAuth({ toActor }),
})

publicUrl must be an origin only — a path, query, or fragment fails the boot, because the endpoint and its .well-known document are always mounted at fixed paths on this app.

Sessions

The transport is stateful. A client initializes without a session id; the server mints one, returns it in the Mcp-Session-Id response header, and keeps a transport (and its own Server instance) in memory keyed by that id. Subsequent requests carry the header. DELETE /mcp closes the session and evicts it; provider shutdown closes them all.

Sessions are per-process

Session state lives in the provider's own memory, so a multi-replica deployment needs sticky sessions on the Mcp-Session-Id header. A request routed to a pod that does not hold the session gets 400 Invalid or missing session ID.

Authentication is re-verified on every request, not only at initialize — so a revoked or expired token stops working mid-session, and the acting actor is re-resolved from fresh auth info each time.

How authorization flows

The token is verified, the resulting McpAuthInfo carries extra.actor, and that actor reaches the tool layer through the transport. From there it is the same two gates the agent loop applies:

  • tools/list returns registry.definitionsFor(actor, policy, allowedTools) — the allowedTools allow-list, then enabled, the role/ability policy and canUse — minus action tools and the kinds only the agent loop serves (delegation, ask, skills, memory). An actor sees only the tools it may call.
  • tools/call refuses a name that is off the list or not exposable (action and loop-served kinds) as an unknown tool, then goes through registry.invoke, which re-checks enabled, policy.can(actor, spec) and canUse before validating the input against the tool's Standard Schema and running the handler.

McpAuthInfo types extra.actor as an Actor (a strategy of your own may put more keys beside it). Code that receives the SDK's own AuthInfo, whose extra is an untyped record, narrows it with isMcpAuthInfo(authInfo) or reads the actor with actorFromAuthInfo(authInfo), which throws when there is none. Both are exported from @adonis-agora/agent/mcp.

To run MCP under abilities instead of roles, hand it the same Bouncer adapter you gave the agent:

config/mcp.ts
import { authzToolAuthorizer } from '@adonis-agora/agent/authz'
import authz from '@adonis-agora/authz/services/main'

export default defineMcpConfig({
  name: 'Acme Agent',
  version: '1.0.0',
  auth: authKitAuth({ toActor: ({ accountId }) => ({ id: accountId }) }),
  authorizer: authzToolAuthorizer({ authz }),
})

See Bouncer-backed authorization. The same fail-closed rule applies here: a tool that declares no ability is never reachable under that adapter.

`allowedTools` applies to both

allowedTools narrows tools/list and is checked again on tools/call, so a client that guesses a name outside the list gets the same "unknown tool" error as for a name that does not exist. It is still a narrowing of what roles/ability allow, never a widening.

Two things MCP does not carry over

action tools are not served over MCP. MCP has no channel to route an approval decision through, so an action tool is neither listed nor callable — tools/call answers it as an unknown tool. A write that should be reachable from an MCP client puts the approval inside the tool's own handler — defineConfirmedTool does that.

The tool context is thinner. MCP calls get { actor, threadId, runId, requestId } derived from the session id (mcp:<sessionId>), a toolCallId and an idempotencyKey minted per tools/call (each call is its own; nothing replays it), and pageContext: { channel: 'mcp' }, so a tool can tell it is answering an MCP client. Nothing else: no persona, no uiCapabilities, no host, and no emitComponent. A tool that reads any of those must tolerate their absence — they are optional on AiToolCtx precisely for this reason. emitUi is present but discards what it is given, and a tool's present hook runs against it: no component and no fallback text reaches the client. The answer is the tool's return value, as one text content block (a string as is, anything else as JSON). See One output, many surfaces.

Writes with a human gate: defineConfirmedTool

An action tool is off the MCP surface by default, and Claude.ai and Claude Desktop have no elicitation, so there is no protocol-level way to ask the user before a write. defineConfirmedTool puts the gate inside the tool, in two calls:

  1. Without confirm: validates everything, writes nothing, returns a preview and a signed confirmToken.
  2. With the same arguments, confirm: true and that token: commits.
app/agent_tools/refund_order.ts
import { defineConfirmedTool, LucidConfirmTokenStore } from '@adonis-agora/agent'
import db from '@adonisjs/lucid/services/db'
import env from '#start/env'
import { z } from 'zod'

export const refundOrder = defineConfirmedTool(
  {
    name: 'refund_order',
    description: 'Refund an order. Returns a preview first; confirm to commit.',
    input: z.object({ orderId: z.string() }),
    roles: ['ADMIN'],
    secret: () => env.get('APP_KEY').release(),
    store: new LucidConfirmTokenStore(db),
  },
  {
    // Runs for the preview AND for the confirmation. Throw to refuse.
    prepare: async ({ orderId }, ctx) => loadRefundableOrder(orderId, ctx.actor),
    preview: (order) => ({ summary: `Refund ${order.total} to ${order.customer}?`, data: order }),
    commit: async (order) => ({ summary: 'Refunded.', data: await refund(order) }),
  },
)
CallReturns
preview{ status: 'preview', summary, data, confirmToken, expiresAt, confirm } — confirm is the instruction to the model: show the preview, then call again with the token.
confirmation{ status: 'done', summary, data }
bad tokenthrows ConfirmTokenError (reason: 'invalid') — missing, expired, or issued for other arguments, another actor, tenant or tool. Nothing is written.
spent tokenthrows ConfirmTokenError (reason: 'used'). Nothing is written.

The token is <expiresAt>.<HMAC-SHA256> over the tool name, ctx.actor.id, ctx.actor.tenantRef, the expiry and the canonical arguments (keys sorted, so key order does not matter). It is stateless and lasts ttlMs (default 15 minutes). secret is required; there is no default.

input is your own schema. confirm and confirmToken are added around it: they are validated by the helper, stripped before your schema runs (a strict schema never sees them), and merged into the JSON Schema tools/list advertises. Do not declare fields with those names yourself. prepare receives the arguments without them.

The tool registers as kind: 'read': that is what keeps it listed and callable over MCP, and the same tool works in the chat loop.

Pass a `store`, or the token is not single use

Without store, a token commits every time it is sent until it expires — a double tap, a network retry or a model repeating the call writes twice. A ConfirmTokenStore marks the token spent: it is claimed right before commit (a refusal in prepare does not spend it) and released if commit throws.

LucidConfirmTokenStore(db) keeps the marks in agent_confirm_token (created by createAgentTables; only the token's SHA-256, the actor and the tool name are stored) and is safe across replicas — the primary key is the lock. InMemoryConfirmTokenStore is for tests and a single process. Call store.purgeExpired() from a scheduled job to drop expired rows.

messages: { confirm, invalid, used } overrides the English defaults ({minutes} in confirm is replaced with the lifetime). canonicalJson, signConfirmToken, verifyConfirmToken and hashConfirmToken are exported for a gate of your own.

Connecting a client

Any MCP client that speaks Streamable HTTP works. For Claude Desktop, add the server to its config:

{
  "mcpServers": {
    "acme-agent": {
      "url": "https://app.example.com/mcp"
    }
  }
}

With authKitAuth() the client discovers /.well-known/oauth-protected-resource/mcp, walks the OAuth flow against your issuer, and thereafter sends Authorization: Bearer <token>. With apiKeyAuth, configure the header directly.

To try it against a local app without a client, the handshake is plain HTTP:

# 1. initialize — note the Mcp-Session-Id in the response headers
curl -i http://localhost:3333/mcp \
  -H 'authorization: Bearer <token>' \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'

# 2. list the tools this actor may call
curl http://localhost:3333/mcp \
  -H 'authorization: Bearer <token>' \
  -H 'mcp-session-id: <the id from step 1>' \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

# 3. call one
curl http://localhost:3333/mcp \
  -H 'authorization: Bearer <token>' \
  -H 'mcp-session-id: <the id from step 1>' \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"getWeather","arguments":{"city":"Lisbon"}}}'

A tool that throws comes back as isError: true content carrying the message, not as a JSON-RPC error — so a ToolForbiddenError reads as a normal (failed) tool result to the assistant, which is what lets a model recover rather than abort.

Schemas over the wire

MCP clients need JSON Schema for each tool's input. The server derives it from the tool's Standard Schema: Zod schemas are converted directly, and any schema exposing ~standard.jsonSchema.input (Valibot, ArkType, Zod 4) is asked for its own. Anything else advertises a permissive { type: 'object', additionalProperties: true } — the real schema is still enforced on the call, so a mis-shaped argument is rejected server-side either way. It just means the client's autocomplete is less helpful, which is a good reason to prefer a convertible schema for tools you intend to expose.

The dependency

@modelcontextprotocol/sdk is a hard dependency of @adonis-agora/agent, not an optional peer — it installs whether or not you mount the provider. Nothing is loaded at runtime unless the MCP provider is registered, but it does show up in your lockfile.

On this page

Partner

Your product as the official integration for Agent, right here.

Become a partner →