Skip to content

mcp

An MCP server for a Void app, signed in with OAuth 2.1. Agents (Claude.ai, ChatGPT, IDEs) act as the user who connected them, in the organization that user had open when they allowed the connection, with that member’s role. Built like Recranet Studio’s server. examples/crm uses it.

  • OAuth: Better Auth’s OAuth 2.1 provider (@better-auth/mcp) and its JWT plugin, as plugins in auth.ts. Clients register themselves (RFC 7591), as the Claude.ai and OpenAI connectors do. Access tokens are JWTs bound to <origin>/mcp and checked against the app’s own keys, read in process.
  • Discovery: every 401 from /mcp carries WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource/mcp", and the app serves that document and the authorization server’s (/.well-known/oauth-authorization-server, with and without the issuer path).
  • Server: stateless at /mcp, one JSON response per POST. MCP 2026-07-28 (server/discover, per-request _meta, the protocol headers checked against the body) and the 2025 era’s initialize, without a session.
  • Tools: defineTool with a Zod input and a context with the user, the organization and the member. A tool is a read or a write, by name by default; writes are refused for a role that can’t change anything (@recranet/flare/access’s viewer).

It needs @recranet/flare/access for the roles.

Four files, all the app’s own.

auth.ts: the provider, its JWT keys, and the pages it sends the user to. Void calls this function per request; pass it the request.

import { mcpAuth } from "@recranet/flare/mcp"
export default defineAuth(({ defaults, request }) => ({
...defaults,
plugins: [
organization({ ac: access.ac, roles: access.roles, creatorRole: "owner" }),
...mcpAuth(request, { loginPage: "/sign-in", consentPage: "/oauth/consent" }),
],
}))

Then pnpm db:generate for the tables (jwks and the oauth* ones).

lib/mcp.ts: the tools.

import { defineTool, mcpServer } from "@recranet/flare/mcp"
const listContacts = defineTool({
name: "list_contacts",
description: "List contacts, newest first.",
input: ContactList, // a Zod schema; tools/list shows its JSON Schema
permission: { contact: ["list"] }, // checked through @recranet/flare/access
run: async (query, ctx) => Contacts.list(ctx.member!, query),
})
export const mcp = mcpServer({ name: "CRM", version: "0.1.0", access, tools: [listContacts] })

routes/mcp.ts:

export { mcp as GET, mcp as POST, mcp as DELETE } from "../lib/mcp.ts"

middleware/00.well-known.ts, for the discovery documents:

export { wellKnown as default } from "@recranet/flare/mcp"

Void doesn’t route files under a dot directory, so routes/.well-known/* can’t serve them; the middleware answers /.well-known/oauth-* and passes everything else on. If other middleware sends signed-out requests to a sign-in page, let /mcp through (examples/crm/middleware/01.session.ts).

The provider sends a signed-out user to loginPage with a signed query. Sign-in must post that query back as oauth_query, and then follow the url in the answer instead of the usual next page:

import { oauthQuery } from "@recranet/flare/mcp/client"
const oauth_query = oauthQuery()
const { data } = await auth.signIn.email({ email, password, ...(oauth_query ? { oauth_query } : {}) })
location.assign(data?.url ?? next)

The first time a client connects, the provider sends the user to consentPage. It shows who asks and posts the answer:

const res = await fetch("/api/auth/oauth2/consent", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ accept: true, oauth_query: oauthQuery() }),
})
location.assign((await res.json()).url)

The client’s name comes from its own registration, so show it as what the client says it is. See examples/crm/pages/oauth/consent.vue.

defineTool({
name: "create_activity",
description: "Log an activity.",
input: ActivityCreate,
permission: { activity: ["create"] },
write: true, // the default for any name but list_*, get_*, read_*, whoami
annotations: { destructiveHint: false },
run: async (input, ctx) => Activities.create(ctx.member!, input),
})

Before a tool runs, the server reads the member row on every call (a removed member or a changed role counts at once, not when the token expires), then:

Tool Refused when
any the user is gone or banned (401)
a write the token has no organization; the user isn’t a member; every role held is read-only (access.isReadOnly, a viewer)
with permission the token has no organization; the user isn’t a member; access.can(role, permission) is false

ctx has user (id, name, email), organizationId (or null), member ({ userId, organizationId, role }, or null), clientId, c (the Hono context) and era. The input is parsed with the tool’s schema first.

A refusal, a Zod error, a ToolError or a 4xx HTTPException (such as requireMember’s, or Void’s validator shape with its issues) goes back as an isError result the model can read and correct. Anything else is logged and answers “Internal error”.

Results are the returned value as structuredContent (an array becomes { items }). 2026-07-28 clients and OpenAI’s connectors get only that; other 2025-era clients also get it as a JSON text block.

A token is for one organization: the session’s active one when the user allowed the client (the provider’s consent reference, carried in the organization_id claim). To connect an agent to another organization, switch to it in the app and connect again. A user without an organization gets a token too; only tools without a permission that read (such as whoami) work with it.

@modelcontextprotocol/server 2.0.0 (what Studio uses) runs on Workers. Measured with wrangler, a Worker with one Zod tool and the SDK’s createMcpHandler (plus Studio’s stateless 2025 transport) against the same Worker with only Zod:

Upload (gzip) Startup (wrangler check startup, 3 runs)
Zod only 41 KiB 13 ms
Zod and the SDK 128 KiB 29 ms

So the SDK is ~87 KiB gzip and ~16 ms of startup on every request that starts an isolate, for features these apps don’t use (subscriptions/listen, input-required rounds, progress over SSE, prompts, completions). The handler here is ~270 lines. Revisit when an app needs one of those.

What this plugin costs the CRM, whose Worker was 483 KiB gzip and ~51 ms to start: 576 KiB and ~57 ms, mostly Better Auth’s OAuth provider and JWT plugin.

  • Keys in process. The JWT is verified with Better Auth’s verifyJwsAccessToken against the app’s /jwks handler, called directly, and cached per isolate. Pointing a verifier at the app’s own JWKS URL means a Worker fetching its own hostname, which works under vite dev and fails deployed (Studio, 2026-08-21).
  • The request’s Better Auth. Void builds Better Auth per request and keeps the instance to itself. mcpAuth adds a small plugin that records the AuthContext under the request it was built for; /mcp and the discovery middleware call Better Auth’s own handler (router from better-auth/api) on it.
  • Only where OAuth is served. The provider reads its oauthResource row whenever an instance starts. mcpAuth returns no plugins for requests outside /api/auth/*, /.well-known/* and /mcp, so pages and the API don’t pay for it. Better Auth’s getSession deletes an expired session on those requests without the provider’s hook that revokes its OAuth tokens; those tokens stay valid until they expire.
  • Tests. Undici’s fetch always sends Sec-Fetch-Mode: cors, so in Wrangler’s test harness the authorize endpoint answers { redirect, url } instead of a 302. examples/crm/test/mcp.test.ts runs the whole flow that way: register, sign in with the signed query, consent, code for token, tool calls.
  • Not here yet: Client ID Metadata Documents (@better-auth/cimd), DPoP-bound tokens, and choosing the organization on the consent page. Scripts use API keys instead.