Quickback Docs

AI Agent Readiness

Every Quickback Worker ships agent-discovery files and an auto-generated MCP server. Let Claude, ChatGPT, Cursor, and Perplexity find and call your API out of the box.

Every compiled Quickback Worker advertises itself to AI agents and MCP clients — no user wiring required. One config flag controls the whole discovery surface.

What the compiler generates

When agents is enabled (the default), the Worker serves:

RouteWhat it is
GET /robots.txtAI-bot policy with the Cloudflare Content Signals preamble and explicit Allow: rules for GPTBot, ClaudeBot, PerplexityBot, Google-Extended, CCBot, Applebot-Extended, Bytespider, MistralAI, Cohere, DuckAssist.
GET /llms.txtAgent-facing summary of the API — project name, authentication, resource list, endpoint conventions — derived from your own config. Follows the openapi.publish gate ('authenticated' by default) because it enumerates the same route inventory as the spec.
GET /.well-known/oauth-protected-resourceRFC 9728 metadata declaring this Worker as a protected resource: resource is BETTER_AUTH_URL + /mcp and the issuer BETTER_AUTH_URL + /auth/v1 (or oauth.issuerUrl), whichever host serves the document — the request origin only stands in when BETTER_AUTH_URL is unset.
GET /.well-known/oauth-authorization-serverRFC 8414 authorization-server metadata. When the oauthProvider plugin is enabled it delegates to Better Auth's real OAuth 2.1 surface (/oauth2/authorize, /oauth2/token, /oauth2/register dynamic client registration, PKCE); otherwise it advertises only the truthful token-mint subset.
GET /.well-known/api-catalogRFC 9727 catalog linking at /openapi.json. Same openapi.publish gate as the spec.
GET /.well-known/mcp.jsonMCP Server Card — discoverable metadata for the MCP server below.
POST /mcpAuto-generated MCP-over-HTTP server. One tool per OpenAPI operation, JWT auth forwarded through the same middleware as the REST API.
POST <path> + GET /.well-known/oauth-protected-resource<path>One per named MCP server (agents.mcp.servers) — a curated tool set with its own OAuth resource URL.

Default behavior

quickback.config.ts
import { defineConfig } from '@quickback/compiler';

export default defineConfig({
  name: 'my-api',
  // …
  // agents: true is the default — no need to write it.
});

Run quickback build and every endpoint above ships in the Worker.

Opting out

quickback.config.ts
export default defineConfig({
  // …
  agents: false,  // disables everything
});

Fine-grained control

Pass an object to toggle individual surfaces:

quickback.config.ts
export default defineConfig({
  // …
  agents: {
    robotsTxt: true,        // default
    llmsTxt: true,          // default
    mcp: true,              // default
    oauthDiscovery: true,   // default
    apiCatalog: true,       // default
    allowAiCrawlers: true,  // default — flip to false to Disallow in robots.txt
  },
});

mcp also accepts an object for an authenticated-only MCP surface:

quickback.config.ts
export default defineConfig({
  // …
  agents: {
    mcp: {
      requireAuth: true,      // /mcp challenges anonymous callers → interactive OAuth
      exposeAuthTools: false, // default — Better Auth endpoints are not catalogued as tools
    },
  },
});

See Interactive OAuth below.

exposeAuthTools controls whether the Better Auth surface (auth__*, auth_admin__*, auth_api_keys__*, auth_passkeys__*, …) appears in the tool catalogue. It is off by default: an agent authenticates through OAuth and should not be offered credential minting (API keys), passkey enrolment, impersonation or password changes as callable tools. The REST endpoints themselves are unaffected, and the access filter below still applies when you opt in.

Opting in exposes auth tools only to callers that authenticated to /mcp with a Better Auth session token. Auth tools dispatch to /auth/v1/…, where Better Auth's bearer plugin rejects OAuth access tokens, JWTs, and API keys with a 401. For any other caller, non-public auth tools are missing from tools/list and tools/call returns isError without dispatching. Public auth tools (sign-up, sign-in, forgot-password) work for every caller. An MCP client connected through OAuth therefore sees no session-gated auth tools.

The MCP server

Tools

Every operation in your generated OpenAPI spec becomes one MCP tool. The tool name is the operation's operationId (e.g. listCustomers, createInvoice, sendInvoice). Each tool's inputSchema is a JSON Schema combining the request body with every parameter in the operation's mounted path — including the :params contributed by a namespace prefix (e.g. /scope/:scopeId/...), so a standalone or namespace action is always callable, not just record-based ones. Nested z.object inputs are preserved as nested objects rather than flattened, so the model sees each field's real type.

tools/call re-enters the same Hono app via app.request(...) — so every existing middleware runs exactly as it would for a direct REST call: authentication, firewall, access control, guards, masking. No bypass.

Path and query arguments go into the URL; the rest become the JSON body. A POST, PUT or PATCH tool always sends a JSON body, even when the operation has no body fields (an action with input: z.object({}) and only path params is sent {}), so it passes the same body validation as a REST call. A DELETE tool sends a JSON body only when the operation declares body fields (batch delete sends { ids, options }); single-record DELETE and GET tools send no body.

Access-filtered tools/list

tools/list only returns tools the calling identity could actually invoke. Each generated tool entry carries the operation's access metadata (roles, userRole, or / and combinators) and tools/list filters against ctx before responding:

  • Unauthenticated callers see only tools whose access declares the PUBLIC pseudo-role, or operations that have no security requirement at all (e.g. sign-in / sign-up).
  • Authenticated callers see tools whose role gate intersects their ctx.roles or ctx.userRole. Platform-operator endpoints don't appear for callers outside the required user roles.
  • Function-form access (escape hatch) is opaque at list time — surfaced to any authenticated caller, then evaluated for real on tools/call.
  • Scoped roles (scope:<kind>:<role>, e.g. scope:event:attendee) are opaque at list time too. They are resolved per request from the path (/event/:eventId/...) and live on ctx.scope.<kind>, not ctx.roles, and tools/list has no scope id to resolve. A tool whose roles include a scoped role is listed for any authenticated caller except an external delegated principal (principals:), so an event attendee with no organization still sees the tools they can call. tools/call enforces the scope for real.
  • Record predicates are dropped from the list-time check (no record yet) and applied normally on the actual call.

This is recon-mitigation, not a security boundary. Hiding a tool from the list never weakens the underlying gate — tools/call still runs the full middleware chain and rejects unauthorized invocations the same way a direct REST call would.

The unauthenticated GET /mcp discovery payload returns server info only; it does not enumerate tool names. Inventory is exclusively available via the access-filtered tools/list.

Request limits

Unless requireAuth is set, /mcp accepts anonymous POSTs, so both the body and the JSON-RPC batch array are attacker-controlled. Two limits apply:

LimitValueBehaviour when exceeded
Request body1 MiB413 with JSON-RPC error -32600
Batch elements50413 with -32600; split the batch

The body is read through a byte counter rather than buffered whole, so an oversized request is cut off mid-stream instead of being materialized first. A size rejection is deliberately distinct from -32700 Parse error — a client told "parse error" would retry the same oversized body.

Batches are dispatched in waves of 8 rather than all at once. Every element becomes its own internal request through the full middleware chain, so peak concurrency stays flat regardless of how large a batch is submitted.

Authentication

MCP clients pass their credentials through the standard Authorization: Bearer <jwt> header. The generated MCP handler forwards the header verbatim into the internal request, and the same JWT middleware that guards /api/v1/* validates it.

Mint a JWT from an authenticated session:

curl https://my-api.example.com/auth/v1/token \
  -H "Cookie: better-auth.session_token=..." \
  -H "Content-Type: application/json"

Interactive OAuth (requireAuth)

Statically pasting a Bearer token works, but it isn't how an MCP connector wants to authenticate — it expects to launch a sign-in flow. By default /mcp serves an anonymous session (the access-filtered public tool list), and a connector that never sees an auth challenge never offers to sign in. Set agents.mcp.requireAuth: true to turn /mcp into an authenticated-only endpoint:

quickback.config.ts
export default defineConfig({
  // …
  agents: { mcp: { requireAuth: true } },
});

This does three things:

  1. Force-enables the oauthProvider + bearer Better Auth plugins — so /oauth2/authorize, /oauth2/token, and /oauth2/register (RFC 7591 dynamic client registration) exist for a connector to discover, with PKCE.
  2. Challenges unauthenticated /mcp requests with 401 and a WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource" header (RFC 9728 §5.1). This is the signal MCP connectors use to start OAuth.
  3. Makes the root /.well-known/oauth-authorization-server doc delegate to Better Auth's real metadata, so discovery resolves to working /oauth2/* endpoints instead of a hand-rolled stub.

The access tokens it issues carry the OAuth issuer (BETTER_AUTH_URL + /auth/v1, or oauth.issuerUrl) and the requested resource as audience — exactly what the generated middleware verifies — with or without auth.jwt and without needing oauth.enabled.

The full handshake a connector runs:

POST /mcp  (no token)
  → 401 + WWW-Authenticate: resource_metadata="…/.well-known/oauth-protected-resource"
GET  /.well-known/oauth-protected-resource   → authorization_servers: [<origin>]
GET  /.well-known/oauth-authorization-server → authorize/token/register + PKCE
POST /oauth2/register                        → dynamic client registration
GET  /oauth2/authorize?code_challenge=…      → redirect to your Account login → consent
POST /oauth2/token  (code + code_verifier)   → access token
POST /mcp  (Bearer access token)             → authenticated tool list

The login and consent screens are served by your Account SPA (ACCOUNT_URL/login, ACCOUNT_URL/consent), so users sign in with whatever methods you already enabled (Google, GitHub, email, passkey). No connector-specific configuration is required — adding the server URL is enough.

requireAuth removes anonymous public-tool access over /mcp. Use it when the MCP surface is meant for authenticated users; leave it off (the default) for servers that expose genuinely public tools.

Split authorization server / resource server (multi-app SSO)

By default a Worker is both the authorization server (AS) and the resource server — it issues and verifies its own OAuth tokens. For multi-app SSO you can run one canonical AS and many resource servers (each hosting its own /mcp) that share a single auth database (the same D1, e.g. via crossSubDomainCookies on a shared parent domain). Two config fields wire it up — both under auth.config.oauth:

On each resource server — point discovery at the external AS and verify its tokens:

auth: {
  config: {
    oauth: {
      enabled: true,
      issuerUrl: "https://auth.example.com/auth/v1",  // the canonical AS
    },
  },
},
agents: { mcp: { requireAuth: true } },

issuerUrl makes /.well-known/oauth-protected-resource advertise the AS (not this Worker), and the generated middleware verifies incoming Bearer access tokens against the shared JWKS (iss must equal issuerUrl, aud must equal this Worker's /mcp, plus exp + live-session binding). Because the auth DB is shared, the resource server reads the AS's signing keys and the session row directly — no cross-Worker HTTP call.

On the authorization server — register the resource audiences it may mint tokens for (RFC 8707 resource indicators):

auth: {
  config: {
    oauth: {
      enabled: true,
      audiences: [
        "https://crm.example.com/mcp",
        "https://billing.example.com/mcp",
      ],
    },
  },
},

audiences is an array — list every federated resource server. The AS's own origin (and /mcp, and every named MCP server URL) is always included automatically, so you only list the other servers; a token requested with resource=https://crm.example.com/mcp is then minted with that audience instead of rejected. A resource server's own named MCP servers are accepted by its middleware automatically, but the AS mints them only if its audiences lists their URLs too.

agents.mcp.requireAuth is the documented switch for an authenticated MCP server, and it now emits the full OAuth token-verification path on its own — a resource server needs only requireAuth + oauth.issuerUrl, not a separate oauth.enabled step.

Named MCP servers

One /mcp exposing every operation is a lot of tools for a model to choose from, and the right set differs by audience — staff need the admin surface, attendees need a handful of "my" tools. agents.mcp.servers adds named servers next to /mcp, served by the same Worker, each with a curated tool list, its own exact OAuth resource URL and its own protected-resource metadata:

quickback.config.ts
export default defineConfig({
  // …
  agents: {
    mcp: {
      requireAuth: true,                 // the default /mcp — unchanged
      servers: {
        guest: {
          path: "/guest/mcp",
          // Optional vanity URL, per named environment (a plain string when
          // the project has no `environments`).
          publicUrl: {
            production: "https://mcp.attend.vip/guest",
            dev: "https://mcp-dev.attend.vip/guest",
          },
          name: "Attend for attendees",   // serverInfo.title
          instructions: "Tools for event attendees: your agenda, RSVPs and events.",
          tools: {
            include: ["agenda_items__listMyAgenda", "agenda_items__selfRsvp", "user_profiles__listMyEvents"],
            // groups: ["agenda_items"],  // every tool in a group (the part before `__`)
            // exclude: ["agenda_items__agendaItemsApprove"],
            overrides: {
              agenda_items__selfRsvp: { title: "RSVP to a session", description: "Accept or decline one agenda item." },
            },
          },
        },
      },
    },
  },
});
OptionRequiredDescription
pathYesWhere the server answers, e.g. /guest/mcp. Plain segments, no trailing slash, outside every system prefix (/api, /auth, /.well-known, /mcp, /storage, …) and every apps.*.path.
tools.includeOne of include / groupsExact tool names, as the default /mcp server lists them (<group>__<operationId>).
tools.groupsOne of include / groupsEvery tool whose name starts with <group>__.
tools.excludeNoTool names removed after include and groups.
tools.overridesNoPer-tool title / description shown by this server only. Keys must be selected tools.
nameNoServer title (serverInfo.title). Defaults to <project> (<key> MCP); serverInfo.name is <project>-<key>.
instructionsNoReturned from initialize as the server's instructions.
requireAuthNoChallenge anonymous callers with 401. Defaults to agents.mcp.requireAuth. Setting it on any server force-enables oauthProvider + bearer, without changing the default /mcp challenge.
publicUrlNoA vanity URL for the server — see below.

The selection is fixed at compile time. A tool name or group the generated API doesn't have fails the compile with a suggestion (unknown tool "events__selfRsvp". Did you mean "agenda_items__selfRsvp"?), and so does a selection that ends up empty or an override for an unselected tool.

What each server gets

  • Curated tools/list and tools/call. The list is the allowlist, with the access filter applied on top — scope-gated tools stay visible to any authenticated caller who could hold the scope. tools/call only dispatches tools in the allowlist (Unknown tool otherwise) and re-enters the app exactly like /mcp: same middleware chain, same {} body for body-less POST/PUT/PATCH tools, no token forwarded to anything else.
  • An exact resource URL. The server's URL is BETTER_AUTH_URL + path (e.g. https://api.attend.vip/guest/mcp). It is added automatically to the oauthProvider resources — so a connector can request a token with resource=<server URL> — and to the audiences the auth middleware accepts.
  • Its own RFC 9728 document at the path-insert URL /.well-known/oauth-protected-resource<path>, with resource exactly equal to the server URL. A 401 from the server carries WWW-Authenticate: Bearer resource_metadata="<that document>". The root /.well-known/oauth-protected-resource keeps describing /mcp. The documents follow agents.oauthDiscovery, like the root one.
  • Discovery. /.well-known/mcp.json gains a servers array listing /mcp and every named server, and /llms.txt lists each one.

The default /mcp is unchanged — same tools, same challenge, same documents.

An access token minted for a named server is accepted anywhere this Worker accepts its own OAuth tokens, exactly like a /mcp token: the curated list decides what the model is offered, not what the token can reach. Every call still runs the full security pipeline.

Public URLs on a vanity host

publicUrl serves the same server at a second URL on a host of your choosing, e.g. https://mcp.attend.vip/guest:

  • Requests whose host and path match it reach the server's handler, and its RFC 9728 document is served on that host at /.well-known/oauth-protected-resource<publicUrl path> with resource equal to the public URL. Other paths on that host behave like any other host of the Worker. The BETTER_AUTH_URL + path URL keeps working alongside it.
  • The public URL is also added to the oauthProvider resources and the accepted audiences, so a connector added with the public URL gets a token for exactly that URL.
  • Its hostname is bound as a custom domain on the same Worker automatically — no providers.runtime.config.routes entry needed. A host the target already routes (its API host, say) adds no second route. See Multi-Domain Architecture.
  • The URL needs a path (https://mcp.attend.vip/guest, or …/mcp on a dedicated host): the host root's /.well-known/oauth-protected-resource is the default server's document. https:// only, no port, query or fragment, and the host can't be a CMS, Account, admin, app, auth or webhook host — their hostname middleware would serve or block the MCP path. On a host the target already serves (its API or BETTER_AUTH_URL host, a routes entry), the path can't be /mcp or any named server's path either: the public URL is matched first and would shadow that server there.

With named environments, key publicUrl by environment name. Every environment runs the same bundle, so the compiler stamps QUICKBACK_ENVIRONMENT = "<name>" into each target's vars whenever a publicUrl is keyed by environment; the Worker reads it to know which public URL is its own. Only that URL is routed and accepted as an audience on that target — mcp-dev.attend.vip is never a resource of production. An environment without an entry serves the server at BETTER_AUTH_URL + path only. Keys must be plain environment names (a letter, then letters, digits, - or _), and never an Object.prototype member such as constructor. A host owned by another target, or a domains: 'none' target with an entry, fails the compile.

Every server — named or not — uses the auth-level pages (auth.config.oauth.loginPage / consentPage, else the Account SPA). Per-server pages are not supported: Better Auth's oauthProvider takes one loginPage and one consentPage and has no hook to pick one per request, so choosing by resource would need a redirect shim in front of it. Setting agents.mcp.servers.<key>.oauth is a compile error that says so. To brand the screens per server, have your login/consent page branch on the resource parameter: when the connector sends one (RFC 8707), it is part of the signed query string the page is opened with.

Connecting clients

Claude Desktop — add to ~/Library/Application Support/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "my-api": {
      "url": "https://my-api.example.com/mcp",
      "headers": {
        "Authorization": "Bearer <jwt>"
      }
    }
  }
}

Cursor — use the same config shape under .cursor/mcp.json.

Quick sanity check with curl:

# List available tools
curl -X POST https://my-api.example.com/mcp \
  -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

# Call a tool
curl -X POST https://my-api.example.com/mcp \
  -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"listCustomers","arguments":{"limit":10}}}'

Transport

The server speaks the Streamable HTTP MCP transport — JSON-RPC 2.0 over POST. A plain GET /mcp returns the server info document for sanity checks.

The MCP route does not emit its own CORS headers. The Worker's existing cors() middleware applies uniformly, so browser-origin access is governed by the same allowlist as the rest of the API (auth.trustedOrigins + the deploy origin). Server-to-server clients (Claude Desktop, Cursor, MCP Inspector) connect with a Bearer token and don't need browser CORS at all. If you want a browser-based MCP client (ChatGPT, Claude.ai) to connect cross-origin, add its origin to auth.trustedOrigins like any other web client.

Verification

After you deploy, scan both the REST and MCP surfaces:

# Agent-readiness scan (external)
open https://isitagentready.com/?url=https://my-api.example.com

# MCP Inspector (official tool)
npx @modelcontextprotocol/inspector https://my-api.example.com/mcp

On this page