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 inauth.ts. Clients register themselves (RFC 7591), as the Claude.ai and OpenAI connectors do. Access tokens are JWTs bound to<origin>/mcpand checked against the app’s own keys, read in process. - Discovery: every 401 from
/mcpcarriesWWW-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’sinitialize, without a session. - Tools:
defineToolwith 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.
Set up
Section titled “Set up”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 sign-in and consent pages
Section titled “The sign-in and consent pages”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.
The organization
Section titled “The organization”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.
Why no MCP SDK
Section titled “Why no MCP SDK”@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
verifyJwsAccessTokenagainst the app’s/jwkshandler, 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 undervite devand fails deployed (Studio, 2026-08-21). - The request’s Better Auth. Void builds Better Auth per request and keeps
the instance to itself.
mcpAuthadds a small plugin that records theAuthContextunder the request it was built for;/mcpand the discovery middleware call Better Auth’s own handler (routerfrombetter-auth/api) on it. - Only where OAuth is served. The provider reads its
oauthResourcerow whenever an instance starts.mcpAuthreturns no plugins for requests outside/api/auth/*,/.well-known/*and/mcp, so pages and the API don’t pay for it. Better Auth’sgetSessiondeletes 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
fetchalways sendsSec-Fetch-Mode: cors, so in Wrangler’s test harness the authorize endpoint answers{ redirect, url }instead of a 302.examples/crm/test/mcp.test.tsruns 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.