BuildBaseBuildBase

MCP and agents

Run an MCP server on your own app with @buildbase/sdk/mcp, so AI agents can sign in as your users and call your product and its BuildBase capabilities.

@buildbase/sdk/mcp turns your app into an MCP (Model Context Protocol) server. An agent such as Claude Code, Cursor or ChatGPT connects to an endpoint on your domain, the user signs in and approves it on BuildBase's hosted login and consent screen, and the agent can then call tools: the BuildBase ones (workspaces, subscription, quotas, credits, feature flags, permissions) and any you write for your own product.

You need it when you want agents to operate your app on a user's behalf. You do not need it for normal human sign-in - that stays in Authentication.

Step by step, with screenshots: Set up agent access.

The entry point is server-only with no React, and it runs on Node 18+, edge runtimes, Deno and Bun. It is split from the core entry because it uses zod at runtime, which is also how you describe tool inputs.

How the pieces fit

BuildBase runs the OAuth flow - dynamic client registration, PKCE, login, consent, refresh. It never mints the access token itself. On every grant it calls a token endpoint on your app, and your app signs the token with its own secret. The per-user BuildBase session travels inside that token as an encrypted sid claim, so your MCP server can call BuildBase as that user without storing anything.

StepWho does itSDK export
Agent discovers your serverYour appcreateAgentStack() discovery routes
User signs in and approvesBuildBasenone - hosted screens
Token minted for the agentYour apphandleAppTokenRequest() + mintAgentToken()
Each MCP request is verifiedYour appbuildbaseAuth()
Tools run under the userYour appbuiltinMcpTools, defineMcpTool()
User sees and revokes the grantYour app<ConnectedAgents />, useConnectedAgents()

Every tool call runs under the granting user's BuildBase session, so the platform enforces that user's real permissions. An agent can never do more than its user could.

Before you start

Before you start

In the BuildBase console, create an OAuth2 agent client: type oauth2, client kind Agent (third-party), PKCE on. Set its Application token URL to https://<your-origin>/api/agent/token, its Application revoke URL to https://<your-origin>/api/agent/token/revoke, and leave Redirect URLs empty, since agents register their own through dynamic client registration. Then under Authentication › Agent readiness, enable it and Let agents register themselves, with that client as the base client.

You also need two secrets on the server:

  • BUILDBASE_AGENT_CLIENT_SECRET - the base client's secret. BuildBase signs its calls to your token and revoke endpoints with it.
  • SYSTEM_SECRET - your app's own signing and encryption key (openssl rand -hex 32). It never leaves your app.

Warning

localhost does not work for the agent flow. BuildBase calls your token endpoint server-to-server and blocks loopback addresses. For local development, run a tunnel (ngrok http 3000) and use the tunnel origin in the console URLs and in siteUrl.

The fast path: createAgentStack

One config gives you the MCP handler, token verification, CORS, the MCP server card, the RFC 9728 protected-resource metadata, the API catalog, the discovery Link header and the rest of the discovery documents (robots.txt, llms.txt, auth.md, security.txt, the Agent Card). Every derived value can be overridden through mcp.* or discovery.*.

// lib/agent.ts
import { createAgentStack, defineMcpTool } from '@buildbase/sdk/mcp';
import { z } from 'zod';

export const agent = createAgentStack({
  serverUrl: process.env.NEXT_PUBLIC_BUILDBASE_SERVER_URL!,
  orgId: process.env.NEXT_PUBLIC_BUILDBASE_ORG_ID!,
  siteUrl: 'https://app.example.com',
  site: { name: 'Acme', description: 'Render images from templates.' },
  secret: process.env.SYSTEM_SECRET!, // wires buildbaseAuth for you
  mcp: {
    path: '/mcp',
    builtinTools: 'readonly',
    tools: [
      defineMcpTool({
        name: 'app_health',
        description: 'Liveness check for this app.',
        inputSchema: z.object({}),
        annotations: { readOnlyHint: true },
        execute: async () => ({ status: 'ok' }),
      }),
    ],
  },
  discovery: {
    security: { contact: 'mailto:[email protected]' },
  },
});

Then wire the routes. In the Next.js App Router each one is a single line:

// app/mcp/route.ts
import { agent } from '@/lib/agent';

export const { GET, POST, DELETE, OPTIONS } = agent.routes;
// app/.well-known/[...path]/route.ts - and the same file at
// app/robots.txt, app/llms.txt, app/auth.md and app/security.txt
import { agent } from '@/lib/agent';

export const GET = agent.serveAgentPath;

Tip

mcp.path defaults to /api/mcp. The starter serves the endpoint at /mcp so the URL equals the canonical protected-resource URI (<host>/mcp); its README notes that MCP clients reject a mismatch.

Outside Next.js, use agent.mcp.handle({ method, headers, body }) for the MCP endpoint (Express, Fastify) or agent.mcp.fetch(request) (Hono, Bun, Deno, edge), and agent.resolvePath(path) for discovery, which returns null for any path it does not own.

Minting the agent's token

BuildBase calls your Application token URL on every grant, including refreshes. handleAppTokenRequest verifies the call and mintAgentToken signs the token: HS256 with your secret, aud bound to the granted resource, scopes carried through, and the session embedded as an encrypted sid. Both run locally with no platform call.

// app/api/agent/token/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { handleAppTokenRequest, mintAgentToken } from '@buildbase/sdk';

export async function POST(request: NextRequest) {
  const { status, body } = await handleAppTokenRequest({
    authorization: request.headers.get('authorization'),
    clientSecret: process.env.BUILDBASE_AGENT_CLIENT_SECRET!,
    mintToken: (claims) =>
      mintAgentToken({ claims, secret: process.env.SYSTEM_SECRET! }),
  });
  return NextResponse.json(body, { status });
}

mintAgentToken also takes expiresInSec (default 3600) and extraClaims, for example a workspaceId your own user sync resolved. The revoke endpoint has a matching handleAppRevokeRequest({ authorization, clientSecret, onRevoke }). Tokens minted this way are short-lived and stateless, so onRevoke only has work to do if you keep a denylist or session store.

Wiring it by hand: createMcpHandler + buildbaseAuth

Use the primitives when you want your own route layout or discovery documents. buildbaseAuth verifies what mintAgentToken minted: the signature, audience binding, and sid decryption, mapped to the McpAuthInfo your tools receive.

// lib/mcp.ts
import BuildBase from '@buildbase/sdk';
import { buildbaseAuth, createMcpHandler } from '@buildbase/sdk/mcp';

const buildbase = BuildBase({
  serverUrl: process.env.NEXT_PUBLIC_BUILDBASE_SERVER_URL!,
  orgId: process.env.NEXT_PUBLIC_BUILDBASE_ORG_ID!,
});

export const mcp = createMcpHandler({
  buildbase,
  serverInfo: { name: 'acme', version: '1.0.0' },
  auth: buildbaseAuth({
    secret: process.env.SYSTEM_SECRET!,
    resource: 'https://app.example.com/mcp',
    requireAudience: true,
  }),
  builtinTools: 'readonly',
});
// app/mcp/route.ts
import { mcp } from '@/lib/mcp';

const handler = (request: Request) => mcp.fetch(request);

export {
  handler as DELETE,
  handler as GET,
  handler as OPTIONS,
  handler as POST,
};

resource accepts an array when the server answers under more than one URI; the first entry is canonical and drives the 401 challenge's resourceMetadataUrl. Setting auth: false disables verification, for local development only. Leave out buildbase entirely to run an MCP server with only your own tools.

createMcpHandler also takes the production controls: rateLimit(auth, req) (return false or { ok: false, retryAfter } for a 429 - the SDK keeps no counters, so back it with your own store), maxRequestBytes (default 1 MiB), allowedOrigins, cors (on by default, *), onError and formatToolError for redacting errors before they reach the agent. With createAgentStack, pass these under mcp.handler.

Built-in tools

builtinMcpTools holds 42 tools covering workspaces, users, subscription, plans, invoices, quotas and usage, credits, feature flags, permissions and settings. Which ones you expose is your call through builtinTools:

ValueExposes
'readonly'Reads only. The default whenever a buildbase client is configured
'all'Reads, writes and destructive or billing operations (delete_workspace, cancel_subscription, purchase_credits)
falseNone - custom tools only
{ include: [...] }Exactly the named tools
{ exclude: [...] }All reads, minus the named tools

selectBuiltinTools(selection) resolves the same value to the tool list, if you want to inspect it. Workspace-scoped tools take an optional workspaceId and fall back to the workspace pinned on the token. A custom tool with a built-in's name replaces the built-in.

Set builtinResources: true to also serve read-only context a host can load without spending tool calls: buildbase://profile, buildbase://workspaces and buildbase://workspace/{workspaceId}.

Your own tools, resources and prompts

defineMcpTool infers the input type from its zod schema. execute gets the parsed input and a context: ctx.bb (BuildBase actions under the user's session), ctx.auth (userId, sessionId, scopes), ctx.workspaceId, and ctx.custom from your context factory.

import {
  defineMcpPrompt,
  defineMcpResource,
  defineMcpTool,
  mcpImage,
  mcpText,
} from '@buildbase/sdk/mcp';
import { z } from 'zod';

const renderImage = defineMcpTool({
  name: 'render_image',
  description: 'Render an image from a template.',
  inputSchema: z.object({ templateId: z.string() }),
  requiredScopes: ['render:execute'],
  execute: async (input, ctx) => {
    await ctx.bb.usage.record(ctx.workspaceId!, {
      quotaSlug: 'images',
      quantity: 1,
    });
    const png = await render(input.templateId); // your code, base64
    return { content: [mcpText('Rendered.'), mcpImage(png, 'image/png')] };
  },
});

const guide = defineMcpResource({
  uri: 'app://docs/getting-started',
  name: 'getting-started',
  mimeType: 'text/markdown',
  read: () => gettingStartedMarkdown,
});

const weeklyReport = defineMcpPrompt({
  name: 'weekly_report',
  arguments: [{ name: 'workspace', required: true }],
  get: (args) => `Summarize this week's usage for ${args.workspace}.`,
});

Pass them as tools, resources and prompts to createMcpHandler (with createAgentStack, tools goes under mcp and the other two under mcp.handler). A tool that returns a plain object is sent as JSON text plus structuredContent; return an McpToolResult built with mcpText, mcpImage, mcpAudio, mcpResourceLink or mcpEmbeddedResource for full control. defineMcpResourceTemplate covers parameterized URIs such as app://orders/{orderId}.

requiredScopes hides the tool from tools/list and refuses the call unless the token carries every listed scope. A token with no scopes sees only tools that require none. The scope names are yours - declare them in the stack's scopes catalog so they appear in the protected-resource metadata and on the consent screen.

The user's side

Users need to see which agents they connected and cut one off. Pass the endpoint to the provider with the mcp prop:

<SaaSOSProvider
  // ...
  mcp={{ url: 'https://app.example.com/mcp', name: 'Acme' }}
>

That adds a Connect an agent guide to the Connected Agents settings screen, with setup steps for ChatGPT, Claude, Cursor, VS Code, Windsurf and Cline by default; replace them with your own McpClientGuide entries through mcp.clients. The screen itself is <ConnectedAgents />, the guide alone is <ConnectMcpGuide />, and useConnectedAgents() gives you the list and revoke() for a custom UI. See Connected agents.

Agent accounts without a browser

Everything above acts for a person who signed in and approved. Some agents have no person behind them and need an account of their own. POST /api/v1/auth/agent/register creates one: a user marked as an agent and linked to the client that created it, already verified (no verification email goes out), with a session token in the response. The account is an ordinary user with no console access. There is no SDK method for it; it is one HTTP call from your server.

It is off by default, and a call succeeds only when all three hold:

  • Your server makes it, as one of your OAuth clients. Send that client's client_id and client_secret in the body, or as HTTP Basic. The secret stays on your server, so the agent never holds it. A missing client, an unknown one or a wrong secret answers 401 with invalid_client.
  • The organization has opted in. In the console under Authentication › Agent readiness, switch on Allow agent self-signup. It needs a plan that includes agent readiness, and on a plan without it the switch reads as off. Off answers 403 with access_denied.
  • There is room under your plan's end-user limit. An agent account counts as a user. At the limit the answer is the same 403, so the caller learns nothing about your plan.
// On your server - never in the browser or inside the agent.
const res = await fetch(
  `${process.env.NEXT_PUBLIC_BUILDBASE_SERVER_URL}/api/v1/auth/agent/register?org=${process.env.NEXT_PUBLIC_BUILDBASE_ORG_ID}`,
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      client_id: process.env.NEXT_PUBLIC_BUILDBASE_CLIENT_ID,
      client_secret: process.env.BUILDBASE_CLIENT_SECRET,
      email: '[email protected]',
      name: 'Invoice bot',
      password: agentPassword, // generated and stored by your server
      context: { timezone: 'UTC', language: 'en' },
    }),
  }
);
const body = await res.json();
if (!res.ok || !body.success) {
  throw new Error(body.message ?? body.error_description);
}
const { token } = body.data; // the agent's session token
FieldRequiredNotes
emailYesThe account's address, unique in your app
nameYesAt least 2 characters
passwordYesAt least 8 characters. The agent can sign in with it later through /api/v1/auth/login?org=<orgId>, while your organization has an Email/Password method
contextNotimezone, country, language and currency, all strings
client_id, client_secretYesOr HTTP Basic in their place

Any other field is refused with 400, as is a missing or too-short one.

StatusBodyWhen
200{ success: true, data: { user: { name, role }, token } }The account was created
200{ success: false, message }Not created, for example because the address is taken - check success, not only the status
401{ error: 'invalid_client', error_description }Client authentication failed
403{ error: 'access_denied', error_description }Self-signup is off, the plan does not include agent readiness, or the end-user limit is reached
429Too many calls. The endpoint shares the sign-in rate limit

Reference implementation

nextjs-agent-mcp-starter is a Next.js app built on createAgentStack(), verified end to end with Claude Code as the client: discovery, dynamic client registration, consent, token mint and tool calls including writes. It exposes all 42 built-ins to show the surface, plus a CRUD example scoped by ctx.auth.userId, rate limiting and error redaction. Its README says to narrow the built-ins before production - start by removing the destructive group.

To connect an agent to your deployed server:

claude mcp add --transport http my-app https://app.example.com/mcp

MCP clients cache tools/list per connection, so reconnect after you add or change tools.