Skip to main content
Glama
billet-mcp

billet-mcp

by alexpricedev

billet-mcp

A remote MCP endpoint with an OAuth 2.1 sign-in, for Billet apps. Claude connectors (and ChatGPT) can register themselves, send a person through a consent screen, and then call the app's tools as that person.

It owns the generic surface and nothing else:

  • OAuth 2.1 server: dynamic client registration (RFC 7591), authorize with PKCE (S256 only), authorization codes, rotating refresh tokens, revocation of a grant.

  • Discovery: RFC 9728 protected-resource metadata and RFC 8414 authorization-server metadata.

  • The consent screen.

  • A bearer gate whose 401 carries the RFC 9728 resource_metadata pointer.

  • A stateless /mcp endpoint and tool-call auditing.

The app supplies the database, its URL, a logger, its users, its sign-in page and its tools. The package imports nothing Billet-specific and keeps its data in its own mcp_ tables.

Install

bun add @alexpricedev/billet-mcp

Requires Bun (it uses Bun.SQL) and Postgres.

Related MCP server: mcp-app-server

Add the migration

The package ships up and down functions. Wrap them in one migration file of your app's:

// migrations/0xx_add_mcp.ts
import { down as mcpDown, up as mcpUp } from "@alexpricedev/billet-mcp";
import type { SQL } from "bun";

export const up = (db: SQL) => mcpUp(db);
export const down = (db: SQL) => mcpDown(db);

It creates mcp_clients, mcp_grants, mcp_tokens, mcp_auth_requests and mcp_audit. Every constraint and index is named with the mcp_ prefix. identity_id is TEXT with no foreign key, so your user ids can be UUIDs, integers or anything else stable.

Mount the routes

import { createMcp, MCP_CONTINUE_PATH } from "@alexpricedev/billet-mcp";

export const mcp = createMcp({
  sql: db,                                  // a Bun SQL instance
  appUrl: process.env.APP_URL!,             // e.g. https://app.example.com
  log,                                      // { warn, error, info? }(scope, message)
  server: {
    name: "My app",
    version: "1.0.0",
    instructions: "What the model should know about this server.",
  },
  getUser: async (req) => {                 // the signed-in user, or null
    const session = await sessionFrom(req);
    return session ? { id: session.user.id, email: session.user.email } : null;
  },
  findUser: async (id) => lookupUser(id),   // fresh read by id, or null
  signInUrl: "/sign-in",                    // string, or (req) => string
  registerTools: (server, user) => {
    server.registerTool("whoami", { description: "Who am I?" }, async () => ({
      content: [{ type: "text", text: user.email }],
    }));
  },
});

Bun.serve({
  routes: {
    ...mcp.routes,
    // ...your own routes
  },
});

mcp.routes is a plain object of (req: Request) => Response | Promise<Response>:

Route

What

/mcp

the stateless MCP endpoint (bearer token)

/.well-known/oauth-protected-resource and /mcp

RFC 9728 metadata

/.well-known/oauth-authorization-server

RFC 8414 metadata

/mcp/register

dynamic client registration

/mcp/authorize

the consent screen (GET) and its decision (POST)

/mcp/authorize/continue

where sign-in returns to

/mcp/token

code exchange and refresh

The discovery documents must be reachable at the root of your origin, so mount them there.

The seams

Option

Purpose

sql

Bun SQL connection. Grants and tokens use sql.begin transactions.

appUrl

Your public URL. Only its origin is used: it is the OAuth issuer and <origin>/mcp is the resource. Cookies are Secure when it is https.

log

warn/error (and optional info) taking (scope, message).

server

Name, version and instructions the MCP server reports.

getUser(req)

The signed-in user for a browser request: { id, email } with a stable id, or null.

findUser(id)

The user behind a stored grant, or null if they are gone. It runs on every /mcp request, so removing a person from your app ends every token they approved.

signInUrl

Where a signed-out browser goes to sign in.

registerTools(server, user)

Register your tools on the per-request McpServer. user is who the token acts as.

rateLimit?(req, rule)

Recommended: registration and authorize are public and write rows, so without it they are unlimited. Return a Response (usually a 429) to refuse, or null. Called for mcp-register, mcp-authorize, mcp-token (all per IP) and mcp-tool (rule.key is per grant, 120 a minute).

secureHeaders?(req, handler)

Wraps the two HTML routes so your CSP and friends apply.

renderPage?(page)

Replaces the package's plain page chrome. page.bodyHtml is already escaped.

auditTargetKeys?

Tool argument names kept in the audit row (default slot, handle, id, url, revisionId, target). Other arguments are never recorded.

The package's own page uses an inline <style>. If your CSP forbids that, pass renderPage.

Sign-in and coming back

A signed-out visit to /mcp/authorize stores the pending request, sets the HttpOnly mcp_auth cookie (Path=/, ten minutes) and redirects to signInUrl. After your sign-in completes, send the browser on to MCP_CONTINUE_PATH (/mcp/authorize/continue) when a request is pending (hasPendingAuthorize is false once it has expired):

if (await mcp.hasPendingAuthorize(req)) return Response.redirect(MCP_CONTINUE_PATH);

/mcp/authorize/continue shows the consent screen, or redirects to / when there is no pending request.

Connect Claude

  1. Deploy the app with the routes mounted and appUrl set to its public URL.

  2. In Claude, add a custom connector and enter https://your-app.example.com/mcp.

  3. Claude gets a 401 with resource_metadata, discovers the server, registers itself, and opens /mcp/authorize in your browser.

  4. Sign in if you are not, then choose Allow access on the consent screen.

For Claude Code: claude mcp add --transport http my-app https://your-app.example.com/mcp, then /mcp inside Claude Code to sign in.

Behaviour

  • One scope, content. Anything else is refused, not narrowed.

  • Public clients only (token_endpoint_auth_method: none). Registration accepts only Claude's and ChatGPT's callbacks and loopback redirects (http://localhost, 127.0.0.1, [::1]).

  • Lifetimes: authorization code 60 seconds, access token 1 hour, refresh token 30 days, pending authorize request 10 minutes.

  • Codes are single use. Refresh tokens rotate, and presenting a spent one revokes the whole grant.

  • Tokens are 256 random bits, stored only as a SHA-256. An access token is also checked against its grant, its audience (<origin>/mcp) and findUser.

  • The consent form is protected by a double-submit token in an HttpOnly cookie plus an Origin check.

  • JSON-RPC batches are refused, so the rate limit and the audit see every call.

  • There is no development token: OAuth is the only way in.

  • Every tool call writes one mcp_audit row (tool, grant, outcome, target arguments), and so do grant approvals and revocations.

Also exported from createMcp: revokeGrant(grantId, actorId), listGrants() for a connected-apps page, and cleanupExpired() to delete spent credentials (run it now and then).

Development

bun install
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/billet_mcp_test bun run check
bun run build

The tests run the OAuth flow against Postgres and drop and recreate the mcp_ tables, so point DATABASE_URL at a throwaway database.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      C
      maintenance
      Enables deploying a remote MCP server with OAuth 2.1 resource-server authorization, protecting tools behind validated bearer tokens and RFC 9728 discovery.
      MIT
    • A
      license
      Not graded
      quality
      B
      maintenance
      Enables existing apps to expose their users and data to external AI agents via MCP with OAuth 2.1 authentication and scoped tools.
      46 npm
      MIT
    • A
      license
      Not graded
      quality
      B
      maintenance
      Provides an OAuth 2.1 authorization and login layer plus tool registration for MCP connectors, so a connector can be added to Claude clients with only a URL and password. It also shapes tool names, descriptions and input schemas for what Claude clients actually display, returning compact results and readable error messages.
      MIT
    • A
      license
      Not graded
      quality
      B
      maintenance
      Enables MCP clients to securely connect to your data through a remote deployment with OAuth 2.1 and PKCE, configurable sign-in, read/write consent, and audit logging. It exposes public tools without sign-in and authenticated tools that respect each user's permissions while validating arguments and recording every call.
      Apache 2.0