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.
| Step | Who does it | SDK export |
|---|---|---|
| Agent discovers your server | Your app | createAgentStack() discovery routes |
| User signs in and approves | BuildBase | none - hosted screens |
| Token minted for the agent | Your app | handleAppTokenRequest() + mintAgentToken() |
| Each MCP request is verified | Your app | buildbaseAuth() |
| Tools run under the user | Your app | builtinMcpTools, defineMcpTool() |
| User sees and revokes the grant | Your 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:
| Value | Exposes |
|---|---|
'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) |
false | None - 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_idandclient_secretin 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 answers401withinvalid_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
403withaccess_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| Field | Required | Notes |
|---|---|---|
email | Yes | The account's address, unique in your app |
name | Yes | At least 2 characters |
password | Yes | At 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 |
context | No | timezone, country, language and currency, all strings |
client_id, client_secret | Yes | Or HTTP Basic in their place |
Any other field is refused with 400, as is a missing or too-short one.
| Status | Body | When |
|---|---|---|
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 |
429 | Too 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/mcpMCP clients cache tools/list per connection, so reconnect after you add or change tools.