@buildbase/sdk 0.0.53 → 0.0.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/AGENTS.md +16 -6
  2. package/CHANGELOG.md +99 -0
  3. package/LICENSE +21 -0
  4. package/README.md +278 -85
  5. package/dist/css/styles.css +1 -1
  6. package/dist/index.cjs +1 -1
  7. package/dist/index.d.ts +582 -114
  8. package/dist/index.mjs +1 -1
  9. package/dist/mcp/index.cjs +1 -1
  10. package/dist/mcp/index.d.ts +780 -16
  11. package/dist/mcp/index.mjs +1 -1
  12. package/dist/react/chunks/SettingsDialog-B6DhyNae.cjs +2 -0
  13. package/dist/react/chunks/SettingsDialog-K38BI74F.mjs +2 -0
  14. package/dist/react/chunks/SubscriptionDialog-BidcQ_vc.mjs +2 -0
  15. package/dist/react/chunks/SubscriptionDialog-CfDLoOzW.cjs +2 -0
  16. package/dist/react/chunks/ar-CCKONfUU.mjs +2 -0
  17. package/dist/react/chunks/ar-DrHMtT1E.cjs +2 -0
  18. package/dist/react/chunks/de-Dhtorn10.cjs +2 -0
  19. package/dist/react/chunks/de-X9mfnMFM.mjs +2 -0
  20. package/dist/react/chunks/es--8iZ7Trv.mjs +2 -0
  21. package/dist/react/chunks/es-CUR7LB6a.cjs +2 -0
  22. package/dist/react/chunks/fr-BqMs_F5g.cjs +2 -0
  23. package/dist/react/chunks/fr-rUp34NG7.mjs +2 -0
  24. package/dist/react/chunks/hi-B_IiucJZ.mjs +2 -0
  25. package/dist/react/chunks/hi-C2i1_hdv.cjs +2 -0
  26. package/dist/react/chunks/ja-4hLI6wMX.cjs +2 -0
  27. package/dist/react/chunks/ja-CIGOj3vV.mjs +2 -0
  28. package/dist/react/chunks/react-BXTDLnE8.cjs +2 -0
  29. package/dist/react/chunks/react-CXCk05WI.mjs +2 -0
  30. package/dist/react/chunks/zh-BSgd6ai5.cjs +2 -0
  31. package/dist/react/chunks/zh-DyVvcYFC.mjs +2 -0
  32. package/dist/react/index.cjs +1 -1
  33. package/dist/react/index.d.ts +507 -141
  34. package/dist/react/index.mjs +1 -1
  35. package/docs/ARCHITECTURE.md +419 -0
  36. package/docs/ERROR_CODES.md +96 -0
  37. package/docs/MCP-AND-AGENT-READINESS.md +893 -0
  38. package/docs/THEMING.md +67 -0
  39. package/docs/UI-CONFIG.md +207 -0
  40. package/package.json +17 -4
  41. package/dist/react/chunks/SettingsDialog-BxDYsie6.mjs +0 -2
  42. package/dist/react/chunks/SettingsDialog-Dy8W3NA6.cjs +0 -2
  43. package/dist/react/chunks/SubscriptionDialog-6c5dHoQd.mjs +0 -2
  44. package/dist/react/chunks/SubscriptionDialog-CpVnnXJg.cjs +0 -2
  45. package/dist/react/chunks/ar-1Z1MOGAP.mjs +0 -2
  46. package/dist/react/chunks/ar-VGzBv3AZ.cjs +0 -2
  47. package/dist/react/chunks/de-BjdDg60Y.cjs +0 -2
  48. package/dist/react/chunks/de-CXUItplO.mjs +0 -2
  49. package/dist/react/chunks/es-BBPsSZUp.cjs +0 -2
  50. package/dist/react/chunks/es-BTbzVutz.mjs +0 -2
  51. package/dist/react/chunks/fr-Cax48X_S.cjs +0 -2
  52. package/dist/react/chunks/fr-DPMeOQu3.mjs +0 -2
  53. package/dist/react/chunks/hi-BJqvpG6d.cjs +0 -2
  54. package/dist/react/chunks/hi-JfJx-LV7.mjs +0 -2
  55. package/dist/react/chunks/ja-CDGtLzTk.mjs +0 -2
  56. package/dist/react/chunks/ja-DvVyj5xX.cjs +0 -2
  57. package/dist/react/chunks/react-4SlWxHdh.cjs +0 -2
  58. package/dist/react/chunks/react-OL1uSkpr.mjs +0 -2
  59. package/dist/react/chunks/zh-BAcqxkxk.cjs +0 -2
  60. package/dist/react/chunks/zh-BNZ2Ooe8.mjs +0 -2
@@ -0,0 +1,893 @@
1
+ # MCP & Agent Readiness — the complete guide
2
+
3
+ This is the full guide for anyone building an app on `@buildbase/sdk` who wants AI agents to discover it, authenticate to it, and operate it over MCP. It explains **every concept, every document, and every flow** — end to end — for both human readers and AI assistants.
4
+
5
+ If you read nothing else, read [The 60-second model](#the-60-second-model) and [The cold-start flow](#the-cold-start-flow-url--tool-call). Everything after that is depth.
6
+
7
+ **Contents**
8
+
9
+ 1. [The 60-second model](#the-60-second-model)
10
+ 2. [The actors](#the-actors)
11
+ 3. [Architecture at a glance](#architecture-at-a-glance)
12
+ 4. [The cold-start flow: URL → tool call](#the-cold-start-flow-url--tool-call)
13
+ 5. [Agent readiness (discovery)](#agent-readiness-discovery)
14
+ 6. [The MCP server](#the-mcp-server)
15
+ 7. [Authentication, in depth](#authentication-in-depth)
16
+ 8. [Scopes & resources: restrict or open up](#scopes--resources-restrict-or-open-up)
17
+ 9. [Setup](#setup)
18
+ 10. [Spec compliance](#spec-compliance)
19
+ 11. [Verify & troubleshoot](#verify--troubleshoot)
20
+ 12. [API reference](#api-reference)
21
+
22
+ ---
23
+
24
+ ## The 60-second model
25
+
26
+ An **AI agent** (Claude Desktop, ChatGPT, Cursor, the MCP Inspector, or your own) wants to use your app. Three things have to be true:
27
+
28
+ 1. **It can find you.** Your app serves machine-readable _discovery documents_ at well-known URLs — this is "agent readiness."
29
+ 2. **It can log in.** Your app exposes an OAuth flow the agent can walk automatically — discover the auth server, register itself, get a token with PKCE.
30
+ 3. **It can act.** Your app runs an **MCP server** — a small JSON-RPC endpoint that lists _tools_ and runs them, each under the logged-in user's permissions.
31
+
32
+ The SDK gives you all three. **Who owns what** is the key idea:
33
+
34
+ > **BuildBase (the platform) runs the OAuth flow and owns the authorization server. Your app owns everything else** — it mints its own access tokens with its own secret, owns its APIs and MCP tools, and declares its own resources and scopes. **The platform never sees your token or your secret.** That is the security guarantee.
35
+
36
+ The SDK's job is to make your side one config object, while never taking control away: every default is overridable, every restriction is opt-in, and the BuildBase pieces are optional (you can run a standalone MCP server with only your own tools).
37
+
38
+ ---
39
+
40
+ ## The actors
41
+
42
+ Four parties take part. Keep them straight and every flow below reads cleanly.
43
+
44
+ | Actor | What it is | What it holds | What it does |
45
+ | ---------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
46
+ | **Agent / MCP client** | Claude Desktop, ChatGPT, Cursor, MCP Inspector, or your own bot | An access token (after login) | Discovers your app, logs in, calls tools |
47
+ | **Your app** | The site built on `@buildbase/sdk` (your product) | `SYSTEM_SECRET` (signs its own tokens), `BUILDBASE_CLIENT_SECRET` | Serves discovery docs, runs the MCP server, **mints & verifies its own tokens** |
48
+ | **Platform AS** | The BuildBase authorization server | Its own signing keys, the OAuth client registry | Runs login/consent/PKCE, publishes RFC 8414 metadata, calls your app to mint a token |
49
+ | **The SDK** | `@buildbase/sdk` + `@buildbase/sdk/mcp` in your app's process | Nothing — it's a library | Builds discovery docs, runs the MCP handler, does the crypto for mint/verify |
50
+
51
+ The SDK is **not** a network service. It runs inside your app. When this guide says "the SDK verifies the token," it means your app verifies it, using SDK code, with your secret, in your process.
52
+
53
+ ---
54
+
55
+ ## Architecture at a glance
56
+
57
+ ```
58
+ ┌──────────────────────────────────────────────┐
59
+ │ YOUR APP │
60
+ │ (built on @buildbase/sdk) │
61
+ ┌───────────┐ │ │
62
+ │ Agent │ 1.GET │ /.well-known/* ← discovery documents │
63
+ │ / MCP │────────▶│ /robots.txt /llms.txt (agent readiness) │
64
+ │ client │ │ │
65
+ │ │ 4.POST │ /api/mcp ← MCP server (tools) │
66
+ │ │────────▶│ │ │
67
+ │ │ │ └─ buildbaseAuth() verifies YOUR token │
68
+ └─────┬─────┘ │ └─ tools run under the user's session ────┼──┐
69
+ │ │ │ │
70
+ │ │ /api/auth/oauth2-token ← app mints its token │ │
71
+ │ │ (applicationTokenUrl, called by platform) │ │
72
+ │ └───────────────────▲────────────────────────────┘ │
73
+ │ 2. OAuth flow │ 3. "mint a token for this user"│
74
+ │ (authorize, consent, PKCE) │ (signed webhook call) │
75
+ ▼ │ │
76
+ ┌──────────────────────────────────────────┴───────┐ │
77
+ │ PLATFORM AS (BuildBase) │ user's real │
78
+ │ /.well-known/oauth-authorization-server (8414) │ permissions ◀────┘
79
+ │ /authorize /token /register(DCR) /consent │
80
+ └───────────────────────────────────────────────────┘
81
+ ```
82
+
83
+ Read it as: the agent **discovers** (1), runs the **OAuth flow** with the platform (2), the platform **calls your app to mint** the token (3), and the agent then **calls your MCP server** with that token (4). The next section walks all four in order.
84
+
85
+ ---
86
+
87
+ ## The cold-start flow: URL → tool call
88
+
89
+ This is the whole story: an agent that knows only your MCP URL ends up listing and calling tools, with a logged-in user, no manual configuration. It is exactly what the MCP Inspector and Claude Desktop do.
90
+
91
+ ```
92
+ Agent Your app Platform AS
93
+ │ │ │
94
+ │ 1. POST /api/mcp (no token) │
95
+ │────────────────────▶│ │
96
+ │ 401 + WWW-Authenticate: │
97
+ │ resource_metadata="…/.well-known/ │
98
+ │ oauth-protected-resource/mcp" │
99
+ │◀────────────────────│ │
100
+ │ │ │
101
+ │ 2. GET that metadata (RFC 9728) │
102
+ │────────────────────▶│ │
103
+ │ { resource, authorization_servers:[AS], scopes } │
104
+ │◀────────────────────│ │
105
+ │ │ │
106
+ │ 3. GET AS metadata (RFC 8414) ───────────────────────▶│
107
+ │ { authorization_endpoint, token_endpoint, │
108
+ │ registration_endpoint, code_challenge_methods, │
109
+ │ token_endpoint_auth_methods:["none",…] } │
110
+ │◀───────────────────────────────────────────────────────│
111
+ │ │ │
112
+ │ 4. POST /register (DCR, token_endpoint_auth_method=none)│
113
+ │ ───────────────────────────────────────────────────▶│
114
+ │ { client_id } ← public client, NO client_secret │
115
+ │◀───────────────────────────────────────────────────────│
116
+ │ │ │
117
+ │ 5. Browser: /authorize?…&code_challenge=… (PKCE) │
118
+ │ ───────────────────────────────────────────────────▶│
119
+ │ user logs in + consents to the scopes │
120
+ │ ◀── redirect back with ?code=… ────────────────────│
121
+ │ │ │
122
+ │ 6. POST /token (code + code_verifier, NO secret) ─────▶│
123
+ │ │ 6a. "mint a token for this user"│
124
+ │ │◀─────────────────────────────────│
125
+ │ │ 6b. app verifies the call, │
126
+ │ │ mints ITS token, returns it │
127
+ │ │─────────────────────────────────▶│
128
+ │ { access_token } ◀──────────────────────────────────│
129
+ │◀────────────────────────────────────────────────────── │
130
+ │ │ │
131
+ │ 7. POST /api/mcp Authorization: Bearer <access_token> │
132
+ │────────────────────▶│ buildbaseAuth verifies aud+sig, │
133
+ │ │ decrypts sid → user's session │
134
+ │ { tools: [...] } │ tool runs under user's perms │
135
+ │◀────────────────────│ │
136
+ ```
137
+
138
+ **Step by step:**
139
+
140
+ 1. **The 401 challenge.** The agent POSTs to `/api/mcp` with no token. Your MCP handler answers `401` with a `WWW-Authenticate: Bearer resource_metadata="…"` header (RFC 9728 / 6750). That header is the thread the agent pulls.
141
+ 2. **Protected-resource metadata.** The agent GETs the `resource_metadata` URL. It gets back `{ resource, authorization_servers, scopes_supported }` — _which_ auth server to talk to and _what_ scopes exist.
142
+ 3. **Authorization-server metadata (RFC 8414).** The agent GETs the AS metadata. It learns the `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, that PKCE `S256` is required, and that `"none"` (public client) auth is allowed.
143
+ 4. **Dynamic client registration (RFC 7591).** The agent registers itself as a **public client** (`token_endpoint_auth_method: "none"`). The platform issues a `client_id` and **no client secret** — the agent is a native app that can't keep a secret, so PKCE is its authentication instead.
144
+ 5. **Authorize + consent (with PKCE).** The agent opens `/authorize` in a browser with a `code_challenge`. The user logs in and consents to the requested scopes (shown with human descriptions). The platform redirects back with a one-time `code`.
145
+ 6. **Token exchange → your app mints.** The agent POSTs the `code` + `code_verifier` (PKCE) to `/token`, no secret. The platform validates PKCE, then **calls your app's `applicationTokenUrl`** ("mint a token for this user"). Your app verifies that call, mints **its own** access token (with the user's session embedded, encrypted), and returns it. The platform relays it to the agent. **The platform never generated or inspected your token.**
146
+ 7. **The tool call.** The agent calls `/api/mcp` again, now with `Authorization: Bearer <access_token>`. Your MCP handler verifies the signature and audience, decrypts the `sid` claim back into the user's BuildBase session, and runs the tool **under that user's real permissions**.
147
+
148
+ Every arrow above is served by the SDK (your side) or the platform AS (BuildBase's side). You wire two routes; the flow just works.
149
+
150
+ ---
151
+
152
+ ## Agent readiness (discovery)
153
+
154
+ "Agent readiness" = serving the machine-readable documents an AI client looks for. In the SDK, one function resolves the **entire** surface:
155
+
156
+ ```ts
157
+ const doc = await resolveAgentPath(pathname, config); // → { status, contentType, body, cacheControl, vary? } | null
158
+ ```
159
+
160
+ You hand `doc` to your framework's response. `agent.serveAgentPath` (from `createAgentStack`) already wraps this with CORS + the OPTIONS preflight.
161
+
162
+ ### Every document, and who reads it
163
+
164
+ | Path | Standard | What it says | Who reads it |
165
+ | --------------------------------------------------- | -------------------------- | --------------------------------------------- | ---------------------- |
166
+ | `/robots.txt` | RFC 9309 + Content Signals | Crawl rules, AI-bot rules, AI-usage prefs | Crawlers, AI bots |
167
+ | `/sitemap.xml` | Sitemaps | Canonical URLs | Crawlers |
168
+ | `/llms.txt`, `/llms-full.txt` | llmstxt.org | Human-language overview / full docs for LLMs | LLMs reading your site |
169
+ | `/.well-known/agent.json` | Agent Card | Who you are + links to every other doc | Generic agents |
170
+ | `/.well-known/agent-card.json` | A2A | Agent-to-agent card (served by default) | A2A clients |
171
+ | `/.well-known/mcp/server-card.json` | SEP-1649 v1.0 | Your MCP endpoint, transport, capabilities | MCP clients |
172
+ | `/.well-known/mcp.json` | SEP-1960 | Multi-server discovery manifest | MCP clients |
173
+ | `/.well-known/oauth-protected-resource[/…]` | RFC 9728 | This resource + its auth server + scopes | Any OAuth agent |
174
+ | `/.well-known/oauth-authorization-server` | RFC 8414 | The AS endpoints (proxied from platform) | Any OAuth agent |
175
+ | `/.well-known/openid-configuration` | OIDC alias | Same as above, for OIDC probers | OIDC clients |
176
+ | `/.well-known/agent-skills/index.json` + `SKILL.md` | Agent Skills | How-to docs the agent can follow | Agents that self-teach |
177
+ | `/.well-known/api-catalog` | RFC 9727 | Your APIs (openapi, docs, status) | API-consuming agents |
178
+ | `/auth.md` | convention | Human/agent auth instructions | Agents, developers |
179
+ | `/security.txt` | RFC 9116 | Security contact | Researchers, agents |
180
+ | `/.well-known/http-message-signatures-directory` | Web Bot Auth | Your outbound-bot signing keys (opt-in) | Sites your bot calls |
181
+ | `config.extraPaths['/…']` | anything | Any literal doc (x402/UCP/ACP, openapi.json…) | Whatever needs it |
182
+
183
+ DNS-AID records can't be served over HTTP; `buildDnsAidRecords(config)` returns the `_agents` SVCB/HTTPS records to publish at your DNS provider.
184
+
185
+ ### What's yours vs the platform's
186
+
187
+ Almost everything is **yours**, defined locally in `AgentReadyConfig` and served from your origin with no platform round-trip. The **only** platform-owned thing is the pointer to its authorization server: `fetchAgentReadiness(config)` fetches `{ enabled, authorizationServer }`, cached and **fail-soft** (any error → `{ enabled: false }`, so a discovery route never 500s). The RFC 8414 AS metadata is re-served from your origin as a fail-soft proxy of the platform's, so agents that only probe your origin still find it.
188
+
189
+ ### robots.txt, Content Signals, AI bots
190
+
191
+ `config.robots`: `policies` (base `User-agent` groups), `aiBots` (`'allow'` default | `'deny'` | explicit list for the crawlers in `AI_BOT_USER_AGENTS`), `contentSignals` (`{ search, aiInput, aiTrain }` → a `Content-Signal:` directive), `sitemaps`.
192
+
193
+ ### Markdown negotiation & the Link header
194
+
195
+ - `wantsMarkdown(acceptHeader)` — q-value-aware "does this client prefer `text/markdown`?" Use it in middleware to serve a markdown variant of content pages to agents.
196
+ - `negotiateMarkdown(accept, { html, markdown })` — picks the variant, returns a document with `vary: 'Accept'`.
197
+ - `buildDiscoveryLinkHeader(config)` — the `Link` response value advertising your discovery docs. Sync and pure — safe in edge middleware.
198
+
199
+ ---
200
+
201
+ ## The MCP server
202
+
203
+ A live, stateless **Streamable HTTP** MCP server (MCP 2025-11-25, negotiating down to 2024-11-05). It speaks JSON-RPC over a single POST endpoint. Pure functions plus a Web-standard `fetch(Request)` adapter, so it runs on Node 18+, edge, Deno, Bun. Server-only, zero React; the `@buildbase/sdk/mcp` entry is separate because it uses zod at runtime.
204
+
205
+ ### The request lifecycle
206
+
207
+ Every request to your MCP endpoint runs this pipeline (all in `createMcpHandler`):
208
+
209
+ ```
210
+ POST /api/mcp
211
+ │
212
+ ├─ OPTIONS? → 204 preflight (CORS) [before anything]
213
+ ├─ method ≠ POST? → 405
214
+ ├─ body > maxRequestBytes? → 413 [DoS guard]
215
+ ├─ auth.verify(token) → McpAuthInfo | null [YOUR verify / buildbaseAuth]
216
+ │ └─ null → 401 + WWW-Authenticate (RFC 9728)
217
+ ├─ rateLimit(auth, req)? → 429 if refused [your gate, fails closed]
218
+ ├─ dispatch JSON-RPC:
219
+ │ ├─ initialize → protocolVersion + capabilities
220
+ │ ├─ tools/list → tools visible to THIS token's scopes
221
+ │ └─ tools/call → run one tool:
222
+ │ ├─ context(auth, req) → ctx.custom
223
+ │ ├─ validate input (zod or raw JSON Schema)
224
+ │ ├─ ctx.bb = buildbase.withSession(auth.sessionId)
225
+ │ └─ execute(input, ctx) → result (or in-band isError)
226
+ └─ CORS headers on the way out
227
+ ```
228
+
229
+ Two things make this safe by construction: a tool only appears in `tools/list` if the token carries its `requiredScopes`, and every tool runs under the user's BuildBase session, so **the platform enforces the user's real permissions** — an agent can never exceed what the user could do.
230
+
231
+ ### Built-in tools
232
+
233
+ The built-ins cover the full BuildBase surface (workspaces, users, subscription, plans, invoices, usage, credits, features, permissions, settings). Choose how much to expose:
234
+
235
+ ```ts
236
+ builtinTools: 'readonly'; // DEFAULT — reads only (least privilege)
237
+ builtinTools: 'all'; // + writes, destructive & billing ops
238
+ builtinTools: false; // none (custom tools only)
239
+ builtinTools: {
240
+ include: ['get_quota_usage', 'record_usage'];
241
+ } // exactly these
242
+ builtinTools: {
243
+ exclude: ['delete_workspace', 'purchase_credits'];
244
+ } // reads minus these
245
+ ```
246
+
247
+ Built-ins carry **no** scope requirement — the read/write boundary for them is `builtinTools`, not scopes (the agent is the authenticated app user, and the platform still enforces the user's permissions underneath).
248
+
249
+ ### Custom tools
250
+
251
+ ```ts
252
+ import { defineMcpTool } from '@buildbase/sdk/mcp';
253
+
254
+ defineMcpTool({
255
+ name: 'search_orders',
256
+ description: 'Search orders in our own database',
257
+ inputSchema: z.object({ query: z.string() }), // zod → validated + published as JSON Schema
258
+ requiredScopes: ['orders:read'], // optional per-tool gating
259
+ annotations: { readOnlyHint: true },
260
+ execute: (input, ctx) =>
261
+ ctx.custom.db.order.findMany({ where: { name: { contains: input.query } } }),
262
+ });
263
+ ```
264
+
265
+ `ctx` is `{ bb, auth, workspaceId, custom }` — `bb` is the session-scoped BuildBase client, `auth` the verified `McpAuthInfo`, `custom` whatever your `context(auth, req)` factory returned (your Prisma client, services, request info). A custom tool named like a built-in **replaces** it.
266
+
267
+ **Tool results.** Return any JSON-serializable value and the handler wraps it (string → one text block; object → JSON text + `structuredContent`). For full control — images, audio, resource links, mixed blocks, or the `isError` flag — return a wire-shaped `McpToolResult` built with the exported helpers:
268
+
269
+ ```ts
270
+ import { mcpImage, mcpResourceLink, mcpText } from '@buildbase/sdk/mcp';
271
+
272
+ execute: async (input, ctx) => ({
273
+ content: [
274
+ mcpText('Revenue chart for Q2:'),
275
+ mcpImage(chartPngBase64, 'image/png'),
276
+ mcpResourceLink('https://app.example.com/reports/q2.pdf', 'q2-report', {
277
+ mimeType: 'application/pdf',
278
+ }),
279
+ ],
280
+ structuredContent: { totalCents: 1_299_00, currency: 'usd' },
281
+ });
282
+ ```
283
+
284
+ ### Resources & prompts
285
+
286
+ Beyond tools, the handler serves the other two MCP server primitives — both **opt-in**, and the `initialize` capabilities only advertise what you actually configured:
287
+
288
+ - **Resources** (`resources/list` / `resources/read` / `resources/templates/list`) — context a host loads without spending tool calls. `builtinResources: true` exposes a deliberately small BuildBase catalog: `buildbase://profile`, `buildbase://workspaces`, and the `buildbase://workspace/{workspaceId}` template (richer data — subscription, usage, credits — stays tools-only). Add your own with `defineMcpResource` / `defineMcpResourceTemplate`:
289
+
290
+ ```ts
291
+ createMcpHandler({
292
+ buildbase,
293
+ builtinResources: true,
294
+ resources: [
295
+ defineMcpResource({
296
+ uri: 'app://docs/getting-started',
297
+ name: 'getting-started',
298
+ mimeType: 'text/markdown',
299
+ read: () => gettingStartedMarkdown,
300
+ }),
301
+ ],
302
+ resourceTemplates: [
303
+ defineMcpResourceTemplate({
304
+ uriTemplate: 'app://orders/{orderId}',
305
+ name: 'order',
306
+ read: (params, _uri, ctx) => ctx.custom.db.order.find(params.orderId),
307
+ }),
308
+ ],
309
+ });
310
+ ```
311
+
312
+ `read` may return a string (text contents), any object (pretty-printed JSON), or an explicit `{ text }` / `{ blob }` shape. `requiredScopes` gates visibility exactly like tools; an under-scoped token gets the same `-32002` as a nonexistent URI (no existence oracle).
313
+
314
+ - **Prompts** (`prompts/list` / `prompts/get`) — user-facing workflow templates (hosts surface them as slash-commands). No built-ins — they're app-specific by nature:
315
+
316
+ ```ts
317
+ prompts: [
318
+ defineMcpPrompt({
319
+ name: 'analyze_usage',
320
+ description: 'Review quota usage and suggest actions',
321
+ arguments: [{ name: 'workspaceId', required: true }],
322
+ get: async (args, ctx) => {
323
+ const usage = await ctx.bb.usage.getAll(args.workspaceId);
324
+ return `Review this workspace's quota usage and suggest actions:\n${JSON.stringify(usage)}`;
325
+ },
326
+ }),
327
+ ],
328
+ ```
329
+
330
+ `get` may return a string (one user text message) or full `McpPromptMessage[]` with mixed content (text, images, audio, embedded resources).
331
+
332
+ **Deliberately not implemented** (they need a server→client push channel; the stateless transport has none — and that statelessness is what keeps the handler edge-safe): resource subscriptions, `listChanged` notifications, sampling, elicitation, roots. Experimental spec features (tasks) wait until they stabilize.
333
+
334
+ ### Production hardening
335
+
336
+ | Option | Effect |
337
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
338
+ | `maxRequestBytes` | Body cap (default 1 MiB → 413), before parsing |
339
+ | `rateLimit(auth, req)` | Stateless gate; return `false`/`{ok:false,retryAfter}` → 429. Fails **closed** |
340
+ | `allowedOrigins` | DNS-rebinding protection (non-matching browser Origin → 403) |
341
+ | `cors` | On by default (`*`, safe for a Bearer API; reflects `allowedOrigins` when set). `cors:[…]` narrows, `cors:false` off. OPTIONS automatic |
342
+ | `formatToolError(err,{tool})` | Redact client-facing tool-error text; full error still hits `onError` |
343
+ | `onError(err, ctx)` | Fires for auth, rate-limit, context, tool-execution, schema-conversion failures |
344
+
345
+ ### No lock-in
346
+
347
+ `buildbase` is optional (omit it → standalone server with only your tools; `ctx.bb` throws a clear error if touched). `context()` injects your own per-request context. Raw JSON Schema tools skip zod. `handle()` is a pure function you can wrap or route around. `provideWebMcpTools([...])` (core entry) registers in-page tools with WebMCP-capable browsers, independent of the server.
348
+
349
+ ---
350
+
351
+ ## Authentication, in depth
352
+
353
+ ### The trust model
354
+
355
+ The whole design turns on one rule: **your app signs and verifies its own tokens; the platform never holds your signing secret.**
356
+
357
+ - Your app holds `SYSTEM_SECRET` (signs/verifies the access tokens agents carry) and `BUILDBASE_CLIENT_SECRET` (shared with the platform, used to authenticate the platform's calls _to_ your app).
358
+ - The platform holds its own OAuth keys and the client registry. It runs login, consent, PKCE, and refresh, but when it's time to issue an access token it **calls your app** to mint one.
359
+
360
+ Because the platform can't sign a token your app would accept (it lacks `SYSTEM_SECRET`), it can neither forge your tokens nor read what's inside them. That's the guarantee.
361
+
362
+ ### Two paths for minting & verifying
363
+
364
+ **Path A — presets (recommended).** The SDK does the whole mint and the whole verify:
365
+
366
+ ```ts
367
+ // MINT — in your applicationTokenUrl route
368
+ import { handleAppTokenRequest, mintAgentToken } from '@buildbase/sdk';
369
+ const { status, body } = await handleAppTokenRequest({
370
+ authorization: req.headers.get('authorization'),
371
+ clientSecret: process.env.BUILDBASE_CLIENT_SECRET!, // authenticates the platform's call
372
+ mintToken: (claims) => mintAgentToken({ claims, secret: process.env.SYSTEM_SECRET! }),
373
+ });
374
+
375
+ // VERIFY — in your MCP server
376
+ import { buildbaseAuth } from '@buildbase/sdk/mcp';
377
+ auth: buildbaseAuth({
378
+ secret: process.env.SYSTEM_SECRET!,
379
+ resource: ['https://app.example.com/mcp', 'https://app.example.com/api/mcp'],
380
+ requireAudience: true,
381
+ }),
382
+ ```
383
+
384
+ **Path B — bring your own token.** Use this when your app mints **one token format for both its own web sessions and agents** (the reference webapp does this). You still verify the platform's call with `handleAppTokenRequest`, but you sign with your own function and verify with `auth.verify(token, req) → McpAuthInfo`. The SDK exports every primitive: `verifyClientJwt`, `signClientJwt`, `extractBearerToken`, `bearerChallenge`, `createSessionRefCrypto`.
385
+
386
+ ### The mint flow (applicationTokenUrl)
387
+
388
+ The platform calls this on **every** grant (initial code exchange _and_ every refresh):
389
+
390
+ ```
391
+ Platform AS Your app (/api/auth/oauth2-token)
392
+ │ │
393
+ │ POST, Authorization: Bearer <JWT │
394
+ │ signed with BUILDBASE_CLIENT_ │
395
+ │ SECRET; claims = { id, email, │
396
+ │ scope, resource, sessionId, … } │
397
+ │───────────────────────────────────▶│ handleAppTokenRequest:
398
+ │ │ 1. verify JWT (HS256, timing-safe,
399
+ │ │ no alg-confusion, exp required)
400
+ │ │ 2. your mintToken(claims):
401
+ │ │ - upsert the user (your DB)
402
+ │ │ - mintAgentToken: sign HS256 w/
403
+ │ │ SYSTEM_SECRET, aud=resource,
404
+ │ │ sid=encrypt(sessionId)
405
+ │ { success, token, expiresIn } │
406
+ │◀───────────────────────────────────│
407
+ ```
408
+
409
+ `claims.sessionId` is a **per-user BuildBase session** bound to this grant (fresh on every mint, including refresh). You embed it **encrypted** and never store it — so the agent's access to BuildBase simply expires with its token.
410
+
411
+ ### The verify flow (every MCP request)
412
+
413
+ ```
414
+ Agent → POST /api/mcp, Authorization: Bearer <access_token>
415
+ │
416
+ buildbaseAuth.verify(token):
417
+ 1. verifyClientJwt(token, SYSTEM_SECRET) → signature + exp OK? else 401
418
+ 2. aud includes one of `resource`? (RFC 8707) else 401
419
+ (MCP_AUTH_DEBUG=1 logs received vs expected aud here)
420
+ 3. decrypt `sid` claim → BuildBase sessionId (AES-256-GCM)
421
+ 4. map claims → McpAuthInfo { sessionId, userId, workspaceId, scopes, claims }
422
+ │
423
+ → tools run with ctx.bb = buildbase.withSession(sessionId)
424
+ ```
425
+
426
+ If the token has no `sid` (or it fails to decrypt), `sessionId` is empty: built-in BuildBase tools then fail with the platform's own 401 in-band, while your session-less tools (e.g. public content) keep working.
427
+
428
+ ### The session channel (`sid`) — why encrypted
429
+
430
+ JWTs are **signed, not encrypted** — anyone holding the token (including the agent) can read its payload. The BuildBase session is a live credential, so it must never travel in plaintext. The SDK encrypts it into the `sid` claim:
431
+
432
+ - **Cipher:** AES-256-GCM. **Key:** `SHA-256(SYSTEM_SECRET + ":bb-session")`. **Wire format:** `base64url(iv[12] || authTag[16] || ciphertext)`.
433
+ - `createSessionRefCrypto(secret)` gives you `encryptSessionRef` / `decryptSessionRef`. It uses **WebCrypto** (Node 18+, Bun, Deno, edge, browsers) and is **byte-compatible** with a Node `createCipheriv('aes-256-gcm', …)` implementation of the same layout — so an app that mints with Node crypto and verifies with the SDK (or vice-versa) interoperates.
434
+ - The session is **never stored** anywhere — not in a DB, not in a cookie. It lives only inside the token and expires with it.
435
+
436
+ ### Public clients & PKCE (why no client secret)
437
+
438
+ Native agents (Claude Desktop, Cursor, CLIs) can't safely keep a secret. So they register as **public clients** — `token_endpoint_auth_method: "none"` (RFC 7591) — and the platform issues them a `client_id` with **no** `client_secret`. Their authentication at the token endpoint is **PKCE** (RFC 7636) instead:
439
+
440
+ ```
441
+ authorize: client sends code_challenge = BASE64URL(SHA256(code_verifier))
442
+ token: client sends the original code_verifier
443
+ platform: SHA256(code_verifier) must equal the stored code_challenge, else reject
444
+ ```
445
+
446
+ The platform **forces** PKCE for public clients: a token exchange with no `code_verifier` is refused. This is entirely platform-side — your `applicationTokenUrl` mints exactly the same way regardless of client type.
447
+
448
+ ### Refresh & revoke
449
+
450
+ - **Refresh:** the platform calls your `applicationTokenUrl` again with `grantType: "refresh_token"` and a **fresh** `sessionId`. You mint a new access token the same way. For public clients the refresh token itself rotates (OAuth 2.1) and is family-revoked on reuse — all platform-side.
451
+ - **Revoke (optional):** register an `applicationRevokeUrl` and handle it with `handleAppRevokeRequest({ onRevoke })`. The platform POSTs `{ userId, clientId, reason }` (signed with your client secret) when a grant is revoked, so you can drop any server-side state you keep.
452
+
453
+ ### Serving multiple integrations
454
+
455
+ Each integration (an AI agent, Zapier, n8n…) is its own OAuth client with its own id/secret, but they share one `applicationTokenUrl`: the platform sends the caller's `clientId`; pick the matching secret from a map. Selecting by `clientId` is safe — it's public and only _picks_ the key; the HS256 signature is still the real gate.
456
+
457
+ ---
458
+
459
+ ## Scopes & resources: restrict or open up
460
+
461
+ This is the customization model. Everything is opt-in; defaults are least-privilege. **Three levers for scopes, two for resources.**
462
+
463
+ ### Scopes — three levers
464
+
465
+ ```
466
+ ┌─────────────────────────────────────────────────────────────┐
467
+ │ 1. DECLARE (the catalog) │
468
+ │ scopes: [{ name:'designs:read', description:'View …' }] │
469
+ │ → scopes_supported in YOUR RFC 9728 protected-resource │
470
+ │ metadata (served from your origin). Scopes are │
471
+ │ app-owned; the shared AS stays scope-agnostic. │
472
+ ├─────────────────────────────────────────────────────────────┤
473
+ │ 2. GATE (per tool — the restriction) │
474
+ │ defineMcpTool({ …, requiredScopes:['designs:write'] }) │
475
+ │ → hidden from tools/list AND refused unless the token │
476
+ │ carries ALL of them (YOUR scope names) │
477
+ ├─────────────────────────────────────────────────────────────┤
478
+ │ 3. THE FLOOR (always on) │
479
+ │ every tool runs under the user's BuildBase session → │
480
+ │ the platform enforces the user's REAL permissions. │
481
+ │ Scopes narrow within what the user can do; never widen. │
482
+ └─────────────────────────────────────────────────────────────┘
483
+ ```
484
+
485
+ A token with **no** `scope` claim sees only tools that require none — a scoped tool can never be unlocked by an absent claim. Built-in tools are restricted by `builtinTools` (not scopes).
486
+
487
+ ### Resources — two levers (RFC 8707 / 9728)
488
+
489
+ ```
490
+ 1. DECLARE: protectedResources (or createAgentStack derives three —
491
+ the API root, the canonical <host>/mcp, and the endpoint URL)
492
+ → the /.well-known/oauth-protected-resource* metadata
493
+
494
+ 2. BIND: buildbaseAuth({ resource, requireAudience }) enforces the
495
+ token's `aud` — it MUST include one of your resources, else 401.
496
+ This is the whole gate: a token minted for resource A is
497
+ rejected at resource B. The shared authorization server stays
498
+ resource-agnostic — it passes the RFC 8707 `resource` param
499
+ through opaquely; your app owns and enforces the audience.
500
+ ```
501
+
502
+ **The RFC 9728 convention** BuildBase uses: the canonical MCP resource identifier is `<host>/mcp` (metadata at `/.well-known/oauth-protected-resource/mcp`), even when the HTTP route lives elsewhere (e.g. Next.js `/api/mcp`). The 401 `WWW-Authenticate` points there. `createAgentStack` registers all three identifiers so RFC 9728 path-derivation resolves whichever one a client picks.
503
+
504
+ ### The customization spectrum
505
+
506
+ | You want… | Do this |
507
+ | ---------------------------------------------- | ------------------------------------------------------------------------------------ |
508
+ | Safest possible, zero config | `createAgentStack({ secret })` → readonly built-ins, audience-bound, no custom tools |
509
+ | Fine-grained per-tool access | Declare `scopes`, set `requiredScopes` on each custom tool |
510
+ | Widen the built-in surface | `builtinTools: 'all'` or `{ include: [...] }` |
511
+ | Your own token format / unified web+agent auth | `createMcpHandler` + your own `auth.verify` + `resolveAgentPath` |
512
+ | A discovery doc the SDK has no builder for | `discovery.extraPaths['/whatever'] = '…'` |
513
+ | Override any derived value | `mcp.*` / `discovery.*` on the stack, or the builder configs directly |
514
+
515
+ ---
516
+
517
+ ## Setup
518
+
519
+ ### Fast path — `createAgentStack`
520
+
521
+ One config derives the MCP handler, the SEP-1649 server card, the RFC 9728 protected-resource metadata (root + `<host>/mcp` + endpoint), the API-catalog entry, the BuildBase client, `buildbaseAuth`, CORS, and the discovery `Link` header.
522
+
523
+ ```ts
524
+ // lib/agent.ts
525
+ import { createAgentStack, defineMcpTool } from '@buildbase/sdk/mcp';
526
+ import { z } from 'zod';
527
+
528
+ export const agent = createAgentStack({
529
+ serverUrl: process.env.NEXT_PUBLIC_BUILDBASE_SERVER_URL!,
530
+ orgId: process.env.NEXT_PUBLIC_BUILDBASE_ORG_ID!,
531
+ siteUrl: 'https://app.example.com',
532
+ site: { name: 'My App', description: 'What it does.' },
533
+ secret: process.env.SYSTEM_SECRET!, // wires buildbaseAuth
534
+ scopes: [
535
+ { name: 'designs:read', description: 'View designs' },
536
+ { name: 'render:execute', description: 'Render images' },
537
+ ],
538
+ mcp: {
539
+ builtinTools: 'readonly',
540
+ tools: [
541
+ defineMcpTool({
542
+ name: 'render_image',
543
+ description: 'Render an image from a template',
544
+ inputSchema: z.object({ templateId: z.string() }),
545
+ requiredScopes: ['render:execute'],
546
+ execute: (input, { bb, workspaceId }) => renderTemplate(input.templateId),
547
+ }),
548
+ ],
549
+ },
550
+ });
551
+ ```
552
+
553
+ The `agent` object above is framework-agnostic. Wire it with whichever of the two adapters your framework speaks:
554
+
555
+ - **`agent.mcp.fetch(Request) → Response`** — Web standard. Next.js App Router, Hono, Bun, Deno, Cloudflare Workers, any edge runtime. `agent.routes` is a ready-made `{ GET, POST, DELETE, OPTIONS }` over it.
556
+ - **`agent.mcp.handle({ method, headers, body }) → { status, headers, body }`** — pure object in/out. Express, Fastify, Koa, Node `http`, Next.js Pages Router.
557
+ - **`agent.serveAgentPath(Request) → Response`** (fetch-style) or **`agent.resolvePath(path) → DiscoveryDocument | null`** (pure) for the discovery documents.
558
+
559
+ ### Framework recipes
560
+
561
+ Every recipe wires the same two things: the **MCP endpoint** and the **discovery documents**. The mint route (`applicationTokenUrl`) is separate — see [Mint route](#mint-route-applicationtokenurl-per-framework) — because it upserts users in _your_ database, so `createAgentStack` can't own it.
562
+
563
+ **Next.js — App Router**
564
+
565
+ ```ts
566
+ // app/api/mcp/route.ts
567
+ import { agent } from '@/lib/agent';
568
+ export const { GET, POST, DELETE, OPTIONS } = agent.routes;
569
+
570
+ // app/.well-known/[...path]/route.ts — plus identical one-liners at
571
+ // app/robots.txt/route.ts, app/auth.md/route.ts, app/security.txt/route.ts, app/llms.txt/route.ts
572
+ import { agent } from '@/lib/agent';
573
+ export const GET = agent.serveAgentPath;
574
+ export const OPTIONS = agent.serveAgentPath;
575
+ ```
576
+
577
+ **Next.js — Pages Router** (`pages/api/*`, Node `req`/`res`)
578
+
579
+ ```ts
580
+ // pages/api/mcp.ts
581
+ import { agent } from '@/lib/agent';
582
+ export const config = { api: { bodyParser: false } }; // MCP parses the raw body itself
583
+ export default async function handler(req, res) {
584
+ const chunks: Buffer[] = [];
585
+ for await (const c of req) chunks.push(c as Buffer);
586
+ const r = await agent.mcp.handle({
587
+ method: req.method!,
588
+ headers: req.headers as Record<string, string | undefined>,
589
+ body: Buffer.concat(chunks).toString('utf8'),
590
+ });
591
+ res.status(r.status);
592
+ for (const [k, v] of Object.entries(r.headers)) res.setHeader(k, v);
593
+ res.send(r.body);
594
+ }
595
+
596
+ // pages/api/agent/[...path].ts — discovery docs. Only pages/api/** files are
597
+ // API routes, so the well-known paths are rewritten here (next.config.js below).
598
+ export default async function handler(req, res) {
599
+ const doc = await agent.resolvePath(req.url!.split('?')[0].replace(/^\/api\/agent/, ''));
600
+ if (!doc) return res.status(404).json({ error: 'not_found' });
601
+ res.setHeader('Content-Type', doc.contentType);
602
+ res.setHeader('Cache-Control', doc.cacheControl);
603
+ res.send(doc.body);
604
+ }
605
+ ```
606
+
607
+ ```js
608
+ // next.config.js — route the agent paths into the API handler above
609
+ module.exports = {
610
+ async rewrites() {
611
+ return [
612
+ { source: '/.well-known/:path*', destination: '/api/agent/.well-known/:path*' },
613
+ { source: '/robots.txt', destination: '/api/agent/robots.txt' },
614
+ { source: '/sitemap.xml', destination: '/api/agent/sitemap.xml' },
615
+ { source: '/llms.txt', destination: '/api/agent/llms.txt' },
616
+ { source: '/llms-full.txt', destination: '/api/agent/llms-full.txt' },
617
+ { source: '/auth.md', destination: '/api/agent/auth.md' },
618
+ { source: '/security.txt', destination: '/api/agent/security.txt' },
619
+ ];
620
+ },
621
+ };
622
+ ```
623
+
624
+ **Express**
625
+
626
+ ```ts
627
+ import express from 'express';
628
+ const app = express();
629
+ app.use(express.text({ type: '*/*' })); // give MCP the raw JSON string
630
+
631
+ app.all('/api/mcp', async (req, res) => {
632
+ const r = await agent.mcp.handle({
633
+ method: req.method,
634
+ headers: req.headers as Record<string, string | undefined>,
635
+ body: req.body,
636
+ });
637
+ res.status(r.status).set(r.headers).send(r.body);
638
+ });
639
+
640
+ // One middleware serves every discovery document.
641
+ app.use(async (req, res, next) => {
642
+ const doc = await agent.resolvePath(req.path);
643
+ if (!doc) return next();
644
+ res
645
+ .status(doc.status)
646
+ .set('Content-Type', doc.contentType)
647
+ .set('Cache-Control', doc.cacheControl)
648
+ .send(doc.body);
649
+ });
650
+ ```
651
+
652
+ **Fastify**
653
+
654
+ ```ts
655
+ fastify.addContentTypeParser('*', { parseAs: 'string' }, (_req, body, done) => done(null, body));
656
+
657
+ fastify.all('/api/mcp', async (req, reply) => {
658
+ const r = await agent.mcp.handle({
659
+ method: req.method,
660
+ headers: req.headers as Record<string, string | undefined>,
661
+ body: req.body,
662
+ });
663
+ reply.code(r.status).headers(r.headers).send(r.body);
664
+ });
665
+
666
+ fastify.setNotFoundHandler(async (req, reply) => {
667
+ const doc = await agent.resolvePath(new URL(req.url, 'http://x').pathname);
668
+ if (!doc) return reply.code(404).send({ error: 'not_found' });
669
+ reply.code(doc.status).header('Content-Type', doc.contentType).send(doc.body);
670
+ });
671
+ ```
672
+
673
+ **Hono** (Node, Bun, Deno, or Workers)
674
+
675
+ ```ts
676
+ import { Hono } from 'hono';
677
+ const app = new Hono();
678
+ app.all('/api/mcp', c => agent.mcp.fetch(c.req.raw));
679
+ app.get('*', async c => {
680
+ const res = await agent.serveAgentPath(c.req.raw);
681
+ return res.status === 404 ? c.notFound() : res;
682
+ });
683
+ ```
684
+
685
+ **Bun**
686
+
687
+ ```ts
688
+ Bun.serve({
689
+ port: 3000,
690
+ async fetch(req) {
691
+ const { pathname } = new URL(req.url);
692
+ if (pathname === '/api/mcp') return agent.mcp.fetch(req);
693
+ return agent.serveAgentPath(req); // 404 for non-agent paths
694
+ },
695
+ });
696
+ ```
697
+
698
+ **Deno**
699
+
700
+ ```ts
701
+ Deno.serve(req => {
702
+ const { pathname } = new URL(req.url);
703
+ return pathname === '/api/mcp' ? agent.mcp.fetch(req) : agent.serveAgentPath(req);
704
+ });
705
+ ```
706
+
707
+ **Cloudflare Workers / edge**
708
+
709
+ ```ts
710
+ export default {
711
+ async fetch(req: Request): Promise<Response> {
712
+ const { pathname } = new URL(req.url);
713
+ return pathname === '/api/mcp' ? agent.mcp.fetch(req) : agent.serveAgentPath(req);
714
+ },
715
+ };
716
+ ```
717
+
718
+ > Pass secrets via `env` on Workers (no `process.env`): build the stack inside the handler, or once per isolate — `createAgentStack` is cheap and stateless.
719
+
720
+ **React / Vue / any SPA (client-side)**
721
+
722
+ An SPA has no server, so it can't _host_ the MCP server or the discovery documents — those live in your backend (any recipe above). What a browser app _does_ own is **WebMCP**: in-page tools an in-browser agent can call directly via `navigator.modelContext`.
723
+
724
+ ```tsx
725
+ // A React component, mounted once. No-op in browsers without WebMCP.
726
+ import { provideWebMcpTools } from '@buildbase/sdk';
727
+ import { useEffect } from 'react';
728
+
729
+ export function WebMcpTools() {
730
+ useEffect(() => {
731
+ provideWebMcpTools([
732
+ {
733
+ name: 'get_current_selection',
734
+ description: "Return the user's current in-app selection",
735
+ inputSchema: { type: 'object', properties: {} },
736
+ execute: () => getSelectionFromStore(),
737
+ },
738
+ ]);
739
+ }, []);
740
+ return null;
741
+ }
742
+ ```
743
+
744
+ For OAuth from an SPA: the agent (not your React code) runs the browser OAuth flow against the platform; your React app only needs to render your normal login. The MCP server + mint route stay on your backend.
745
+
746
+ ### Mint route (`applicationTokenUrl`) per framework
747
+
748
+ The platform calls this on every grant to mint your token. It's the one route `createAgentStack` can't generate — it touches your database. See [the mint flow](#the-mint-flow-applicationtokenurl).
749
+
750
+ ```ts
751
+ // Next.js App Router — app/api/auth/oauth2-token/route.ts
752
+ import { handleAppTokenRequest, mintAgentToken } from '@buildbase/sdk';
753
+ export async function POST(req: Request) {
754
+ const { status, body } = await handleAppTokenRequest({
755
+ authorization: req.headers.get('authorization'),
756
+ clientSecret: process.env.BUILDBASE_CLIENT_SECRET!,
757
+ mintToken: async claims => {
758
+ await upsertUser(claims); // your DB
759
+ return mintAgentToken({ claims, secret: process.env.SYSTEM_SECRET! });
760
+ },
761
+ });
762
+ return Response.json(body, { status });
763
+ }
764
+
765
+ // Express / Node — same handler, req/res style
766
+ app.post('/api/auth/oauth2-token', async (req, res) => {
767
+ const { status, body } = await handleAppTokenRequest({
768
+ authorization: req.headers.authorization,
769
+ clientSecret: process.env.BUILDBASE_CLIENT_SECRET!,
770
+ mintToken: async claims => {
771
+ await upsertUser(claims);
772
+ return mintAgentToken({ claims, secret: process.env.SYSTEM_SECRET! });
773
+ },
774
+ });
775
+ res.status(status).json(body);
776
+ });
777
+ ```
778
+
779
+ ### Manual wiring
780
+
781
+ When your app mints one token format for both web sessions and agents, or needs custom per-request auth, use the primitives directly:
782
+
783
+ ```ts
784
+ import { createMcpHandler, defineMcpTool, buildbaseAuth } from '@buildbase/sdk/mcp';
785
+ import { resolveAgentPath, type AgentReadyConfig } from '@buildbase/sdk';
786
+
787
+ const mcp = createMcpHandler({
788
+ buildbase,
789
+ serverInfo: { name: 'my-app', version: '1.0.0' },
790
+ auth: buildbaseAuth({ secret, resource }), // or your own auth.verify
791
+ builtinTools: 'readonly',
792
+ tools: [
793
+ /* … */
794
+ ],
795
+ });
796
+
797
+ const agentConfig: AgentReadyConfig = {
798
+ serverUrl,
799
+ orgId,
800
+ siteUrl,
801
+ site: { name },
802
+ scopes: [{ name: 'demo:read', description: '…' }],
803
+ protectedResources: [{ resource: `${siteUrl}/mcp` }], // scopes inherited from catalog
804
+ mcpServerCard: mcp.serverCard({ endpoint: `${siteUrl}/api/mcp` }),
805
+ // skills, apiCatalog, robots, security — all optional
806
+ };
807
+ // route: const doc = await resolveAgentPath(new URL(req.url).pathname, agentConfig)
808
+ ```
809
+
810
+ ### Environment variables
811
+
812
+ | Var | Held by | Purpose |
813
+ | ---------------------------------- | ------------------- | ------------------------------------------------- |
814
+ | `SYSTEM_SECRET` | Your app only | Signs & verifies the access tokens agents carry |
815
+ | `BUILDBASE_CLIENT_SECRET` | Your app + platform | Authenticates the platform's calls to your app |
816
+ | `NEXT_PUBLIC_BUILDBASE_SERVER_URL` | Public | The platform AS base URL |
817
+ | `NEXT_PUBLIC_BUILDBASE_ORG_ID` | Public | Your org id |
818
+ | `MCP_AUTH_DEBUG=1` | Optional | Logs received-vs-expected `aud` on auth rejection |
819
+
820
+ Never expose `SYSTEM_SECRET` or `BUILDBASE_CLIENT_SECRET` to the browser — they sign/authenticate tokens. Only the `NEXT_PUBLIC_*` values are client-safe.
821
+
822
+ ### Production checklist
823
+
824
+ - [ ] **`builtinTools`** set deliberately — default `'readonly'`; prefer an explicit `{ include: [...] }` allowlist over `'all'`.
825
+ - [ ] **`requireAudience: true`** on `buildbaseAuth` (the stack sets it) so audience-less tokens are rejected.
826
+ - [ ] **Short token lifetime** — mint agent tokens with a small `expiresInSec` (≈1h). Refresh re-mints; long-lived agent tokens are a liability.
827
+ - [ ] **`rateLimit`** wired to your own store (KV/Redis/edge), and platform/edge rate limiting in front of `/api/mcp`.
828
+ - [ ] **`maxRequestBytes`** left at the 1 MiB default (or lower); **`allowedOrigins`** set if browser MCP clients connect from known origins.
829
+ - [ ] **`formatToolError`** returns a generic string in production so tool errors don't leak internals (`onError` still gets the full error for your logs).
830
+ - [ ] **HTTPS everywhere** — `siteUrl` is `https://`, so the derived resource URIs, card, and challenge are all TLS.
831
+ - [ ] **`onError` → your logger** for auth, rate-limit, context, and tool failures.
832
+ - [ ] **Verify the discovery surface is live** — run the [cold-start curl walk](#verify--troubleshoot) against production; every well-known URL returns 200 and the 401 challenge points at a real one.
833
+ - [ ] **Scale-out safe** — the MCP server is stateless (no session store); `createAgentStack` is cheap to build per-isolate/worker.
834
+
835
+ ---
836
+
837
+ ## Spec compliance
838
+
839
+ | Spec | Target | Where it lives |
840
+ | --------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
841
+ | MCP protocol | 2025-11-25 | `initialize` advertises it; server card `protocolVersion` |
842
+ | MCP auth | 2025-06-18 | RFC 9728 challenge → RFC 8414 metadata → DCR + PKCE |
843
+ | Server card | SEP-1649 v1.0 | `buildMcpServerCard` — `$schema`, `version:"1.0"`, `transport:{type,url}`, boolean caps |
844
+ | Discovery manifest | SEP-1960 | `buildMcpDiscoveryManifest` — `$schema`, `version:"1.0"`, per-server `transport` object |
845
+ | AS metadata | RFC 8414 | Proxied from platform; advertises `"none"` (public clients) + `S256`. Scope-agnostic — scopes live on your RFC 9728 metadata |
846
+ | Protected resource | RFC 9728 | `buildProtectedResourceMetadata` — root + `<host>/mcp` + endpoint |
847
+ | Resource indicators | RFC 8707 | `resource` passed through the AS → `aud`; `buildbaseAuth` enforces the audience at your resource |
848
+ | Dynamic client registration | RFC 7591 | Platform issues public clients (`none`, no secret) |
849
+ | PKCE | RFC 7636 | Forced for public clients; token exchange requires `code_verifier` |
850
+ | Bearer challenge | RFC 6750 | `bearerChallenge` / the handler's 401 |
851
+ | isitagentready.com | — | robots + Content Signals, sitemap, Link header, markdown negotiation, llms.txt/-full, Agent + A2A cards, agent-skills, API catalog, MCP server card, OAuth discovery, Web Bot Auth (opt-in), DNS-AID (records) |
852
+
853
+ ---
854
+
855
+ ## Verify & troubleshoot
856
+
857
+ **List tools with the real MCP client:**
858
+
859
+ ```bash
860
+ npx @modelcontextprotocol/inspector --cli <mcp-url> --transport http \
861
+ --header "Authorization: Bearer <token>" --method tools/list
862
+ ```
863
+
864
+ **Walk the discovery chain by hand** (this is what a cold agent does):
865
+
866
+ ```bash
867
+ # 1. the 401 points you at the resource metadata
868
+ curl -sS -i -X POST <app>/api/mcp -H 'content-type: application/json' \
869
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | grep -i www-authenticate
870
+ # 2. → 3. follow resource metadata → authorization_servers → AS metadata
871
+ curl -sS <app>/.well-known/oauth-protected-resource/mcp | jq
872
+ curl -sS <app>/.well-known/oauth-authorization-server | jq # token_endpoint_auth_methods must include "none"
873
+ ```
874
+
875
+ | Symptom | Likely cause | Fix |
876
+ | ----------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------- |
877
+ | Agent gets `401` and stops | `WWW-Authenticate` missing or points at a 404 | Set `auth.resourceMetadataUrl` (or use `createAgentStack`); confirm the URL returns 200 |
878
+ | Token rejected with `401` | `aud` mismatch | Set `MCP_AUTH_DEBUG=1`; ensure the token's `aud` includes your `resource` |
879
+ | Built-in tools fail but custom tools work | Token has no/undecryptable `sid` | Confirm the platform sends `sessionId` and you embed it; same `SYSTEM_SECRET` on mint + verify |
880
+ | Tool missing from `tools/list` | Its `requiredScopes` aren't in the token | Grant the scope, or drop the requirement |
881
+ | Client can't register | DCR disabled for the org | Enable `agentReadiness.dynamicClientRegistration` in org settings |
882
+
883
+ ---
884
+
885
+ ## API reference
886
+
887
+ **`@buildbase/sdk/mcp`** — `createAgentStack`, `createMcpHandler`, `defineMcpTool`, `builtinMcpTools`, `selectBuiltinTools`, `buildbaseAuth`, `mintAgentToken`, `createSessionRefCrypto`; re-exports `signClientJwt`, `verifyClientJwt`, `extractBearerToken`, `bearerChallenge`, `AppBridgeError`. Types: `AgentStack`, `AgentStackConfig`, `CreateMcpHandlerConfig`, `McpHandler`, `McpToolDefinition`, `McpToolContext`, `McpAuthInfo`, `McpHttpRequest`, `McpHttpResponse`, `McpToolAnnotations`, `McpBuildBaseClient`, `BuiltinMcpToolName`, `BuildBaseAuthOptions`, `MintAgentTokenOptions`, `SessionRefCrypto`, `McpServerCard`.
888
+
889
+ **`@buildbase/sdk` (core)** — discovery: `resolveAgentPath`, `resolveWellKnown`, `fetchAgentReadiness`, `clearAgentReadinessCache`, `buildAgentCard`, `buildA2AAgentCard`, `buildProtectedResourceMetadata`, `buildAgentSkillsIndex`, `buildSkillMd`, `buildSecurityTxt`, `buildLlmsTxt`, `buildLlmsFullTxt`, `buildRobotsTxt`, `buildSitemap`, `buildApiCatalog`, `buildMcpServerCard`, `buildMcpDiscoveryManifest`, `buildAuthMd`, `buildWebBotAuthDirectory`, `buildDnsAidRecords`, `buildDiscoveryLinkHeader`, `wantsMarkdown`, `negotiateMarkdown`, `provideWebMcpTools`, `sha256Digest`, `AI_BOT_USER_AGENTS`. App-bridge: `handleAppTokenRequest`, `handleAppRevokeRequest`, `verifyAppTokenRequest`, `verifyAppRevokeRequest`, `appTokenSuccess`, `appTokenFailure`. Presets: `mintAgentToken`, `buildbaseAuth`, `createSessionRefCrypto`. Types: `AgentReadyConfig`, `AgentReadinessBundle`, `AgentSkill`, `AppScope`, `ApiCatalogApi`, `A2ACardConfig`, `A2ACardSkill`, `DnsAidRecord`, `McpServerCard`, `RobotsConfig`, `RobotsPolicy`, `ContentSignals`, `SitemapUrl`, `DiscoveryDocument`, `WebMcpTool`, `AppTokenRequestClaims`, `AppRevokeRequestClaims`, `AppTokenResult`, `AppTokenResponseBody`, `HandlerResult`, `VerifyClientJwtOptions`.
890
+
891
+ ---
892
+
893
+ _This guide is the canonical reference. The README's Agent Readiness / MCP Server / OAuth2 App Bridge sections are the short overview and link here; `AGENTS.md` carries the dense per-symbol reference._