# 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](/authentication/overview).

Step by step, with screenshots: [Set up agent access](/sign-in-setup/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    | ``, `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

> **Note:**
  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.

> **Note:**
  `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.*`.

```ts
// 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:security@example.com' },
  },
});
```

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

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

export const { GET, POST, DELETE, OPTIONS } = agent.routes;
```

```ts
// 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;
```

> **Note:**
  `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.

```ts
// 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.

```ts
// 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',
});
```

```ts
// 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.

```ts
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:

```tsx
<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 ``, the guide alone is ``, and `useConnectedAgents()` gives you the list and `revoke()` for a custom UI. See [Connected agents](/pre-built-ui/overview#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.

```ts
// 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: 'invoice-bot@agents.example.com',
      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`](https://github.com/buildbase-app/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:

```bash
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.
