# MCP server

Use formbase from Claude, Cursor, and other MCP-compatible tools.

## MCP server

The Model Context Protocol (MCP) server lets AI agents read and edit your formbase forms with rich, schema-driven tools.

<h2 id="what">What it is</h2>
<p>
  MCP is an open standard for AI tools to connect to external services. formbase exposes a hosted MCP endpoint that any MCP-compatible
  client can connect to, including Claude Code, Claude desktop, and Cursor.
</p>

<h2 id="connection">Connection</h2>

```
URL:  https://api.formbase.so/api/mcp   (POST, streamable HTTP)
Auth: Bearer <token>
```

<p>Two kinds of bearer token work:</p>
<ul>
  <li>
    <strong>API token</strong> (<code>fb_...</code>) — created from <a href="/developers/api-tokens">API tokens</a>. Best for personal use
    and quick setup.
  </li>
  <li>
    <strong>OAuth access token</strong> (<code>fbo_...</code>) — issued by the <a href="#oauth">OAuth flow</a>. Best for third-party apps
    connecting on behalf of a user.
  </li>
</ul>
<p>
  Both are bound to exactly one workspace and reach the same tools. A tool call that names another workspace, or a form in one, fails with{' '}
  <code>FORBIDDEN</code>. OAuth tokens also carry scopes (<code>mcp:read</code>, <code>mcp:write</code>, <code>offline_access</code>), but
  no tool is gated on them today — treat any token as full access inside its workspace.
</p>
<p>
  Tool calls are limited to 120 per minute per token, shared with the <a href="/developers/rest-api">API</a>: only <code>tools/call</code>{' '}
  spends the budget, while <code>initialize</code>, <code>tools/list</code>, <code>prompts/*</code> and <code>resources/*</code> are free.
  Over budget, the call still returns HTTP 200 with a failed tool result carrying <code>RATE_LIMITED</code> and a <code>retryAfterMs</code>{' '}
  — poll on a timer, never in a loop.
</p>

> 💡 **Where to get a token**
> <p>
>     Open <strong>OAuth and API Keys</strong> in your workspace sidebar to create API tokens and view connected OAuth apps. See{' '}
>     <a href="/developers/api-tokens">API tokens</a> for a step-by-step guide.
>   </p>

<h2 id="core-tools">Core tools</h2>
<p>
  Every tool is advertised on <code>tools/list</code> when a client connects. Clients that load schemas on demand, like Claude Code, fetch a
  tool's full schema when a task needs it. The table below covers the core tools most tasks start with; <code>load_tools</code> (catalogs)
  and <code>load_skill</code> (domain guides) document the rest.
</p>

<p>
  More insert variants — time, file upload, signature, payment, matrix/grid, ranking, picture choice, toggle switch, table, list, row,
  calculated field, hidden field, inline variable, embedded content (<code>editor_insertEmbedded</code> for YouTube, Google Maps, or iframe
  embeds), and a conditional-logic block (<code>editor_insertLogic</code>) — are on <code>tools/list</code> too. Load{' '}
  <code>load_skill("question-types")</code> for the full set, each with its tool name and fields. Conditional logic is authored with{' '}
  <code>editor_setLogic</code> in the <a href="#tool-catalogs">editor-actions</a> catalog.
</p>

<h2 id="tool-catalogs">Tool catalogs</h2>
<p>
  These tools are on <code>tools/list</code> as well. Run <code>load_tools</code> with a catalog name to get enriched documentation (intro,
  full schemas, usage patterns, edge cases) for the grouped tools, then call them directly.
</p>

<p>
  The <code>request-lifecycle</code> catalog lists all eight request tools because the in-app chat advertises a smaller core set. Over this
  endpoint all eight are already on <code>tools/list</code>, so what the catalog adds is documentation.
</p>

<h2 id="skills">Skills (domain knowledge)</h2>
<p>
  Skills are built-in guides the agent can load via <code>load_skill</code>. They provide domain knowledge that helps the agent make better
  decisions — not tool schemas, but design advice and field semantics.
</p>

<h2 id="requests">Requests</h2>

<p>
  A <a href="/requests/overview">request</a> assigns one published form to one named recipient, with its own link, its own prefilled
  answers, and its own outcome. It is how an agent asks a real person for something and finds out what they said.
</p>

<h3 id="requests-create">Creating a request</h3>

<p>
  Always start with <code>fields_list(formId)</code>. It returns the addressable keys of the form's current published version, each with a{' '}
  <code>usage</code> line saying which argument the key belongs in — visible questions go in <code>prefill</code>, hidden fields in{' '}
  <code>context</code>. Never derive a key from a question title, and re-read after <code>form_publish</code>.
</p>

```
{
  "formId": "j57...",
  "recipient": { "email": "ada@acme.com", "name": "Ada" },
  "prefill": { "company_name": "Acme", "plan": "pro" },
  "readonly": ["company_name"],
  "context": { "crm_id": "A-42" },
  "metadata": { "run_id": "exec_918" },
  "delivery": "email",
  "expiresAt": 1780000000000,
  "callbackUrl": "https://hooks.acme.com/formbase",
  "idempotencyKey": "po-42"
}
```

<p>The result carries the link and the clock:</p>

```
{
  "id": "kd7...",
  "status": "pending",
  "url": "https://form.formbase.so/r/rq_...",
  "deliveryStatus": "queued",
  "expiresAt": 1780000000000,
  "createdAt": 1747000000000,
  "deduplicated": false,
  "next": "..."
}
```

<p>
  <code>delivery</code> defaults to <code>"none"</code>, which hands you <code>url</code> to deliver yourself; <code>"email"</code> sends
  the invitation and needs <code>recipient.email</code> on a Pro or Business plan, or one of a Free account's 10 free invitations.{' '}
  <code>readonly</code> locks fields the recipient may not edit, and every locked key must also be prefilled. <code>context</code> only
  takes hidden-field keys, while <code>metadata</code> is opaque bookkeeping echoed back on <code>request_get</code> and in the callback.{' '}
  <code>expiresAt</code> is epoch milliseconds, defaulting to 30 days out and capped at 365 days. <code>idempotencyKey</code> is
  workspace-scoped for 30 days: the same key with the same body returns the original request with <code>deduplicated: true</code>, and a
  different body is a conflict. Every response also carries a <code>next</code> line telling the agent what to do from here.
</p>
<p>
  <code>deliveryStatus</code> is <code>not_requested</code> until an invitation is queued, then <code>queued</code> → <code>sent</code> or{' '}
  <code>failed</code>, and <code>bounced</code> once the mail provider reports a hard bounce or a complaint. Creating a request spends one
  unit of the workspace&apos;s monthly allowance whether or not the recipient ever answers; once it is gone, <code>request_create</code>{' '}
  fails with <code>MONTHLY_ALLOWANCE_REACHED</code>.
</p>

<h3 id="requests-callbacks">Callbacks or polling</h3>

<p>
  With a <code>callbackUrl</code>, formbase POSTs once per terminal event — completion, expiry, cancellation — signed with the workspace
  request signing secret. See <a href="/requests/callbacks">Callbacks and signing</a> for the payload and the verification recipe.
</p>

> ℹ️ **Autonomous agents should poll**
> <p>
>     The request signing secret only ever appears on the Credentials page in your workspace — it is never returned over MCP or the API. An
>     agent running on its own, with no human to stand up and configure a receiver, therefore cannot verify a callback. Leave{' '}
>     <code>callbackUrl</code> off and poll <code>request_get(requestId)</code> instead, on the order of minutes rather than seconds, until{' '}
>     <code>status</code> leaves <code>"pending"</code>. <code>expiresAt</code> bounds how long that is worth doing.
>   </p>

<h3 id="requests-test-mode">Test mode</h3>

<p>
  Pass <code>test: true</code> to rehearse the whole shape before a real run. The link still opens and can be completed, and the callback
  fires with <code>"test": true</code> — but nothing is emailed whatever <code>delivery</code> says, the request stays hidden from the
  Requests page and the analytics funnel, and its submission counts nowhere: no quota, no exports, no integrations. Test requests only
  appear in <code>request_list</code> when you pass <code>includeTest: true</code>. The link closes within 24 hours, and on Free a workspace
  may create 10 test requests a day.
</p>

<h3 id="requests-documents">Per-request documents</h3>

<p>
  To hand one recipient a file — a contract draft, their own quote — the form needs a <strong>Documents block</strong>, which an author or
  an agent inserts with <code>editor_insertDocumentsBlock</code>. <code>fields_list</code> reports it as <code>type: "documents"</code>. The
  bytes never travel through a tool:
</p>

<ol>
  <li>
    Call <code>document_create</code> with <code>formId</code>, <code>name</code>, <code>contentType</code>, and the exact <code>size</code>{' '}
    in bytes. You get back <code>{'{ id, name, contentType, size, uploadUrl, expiresAt }'}</code>. PDF and images only (no Office
    documents), 25 MB per file, and 100 MB of documents per request.
  </li>
  <li>
    <code>PUT</code> the raw bytes to <code>uploadUrl</code> within the hour, with <code>Content-Type</code> set to the type you declared.
  </li>
  <li>
    Reference it from <code>request_create</code>: <code>documents: [{'{ documentId, field?, name? }'}]</code>. <code>field</code> is the
    Documents block's field key, optional only when the form has exactly one such block. <code>name</code> overrides the display name for
    this request.
  </li>
</ol>

<p>
  The block's authored documents stay put and yours appear below them, for this recipient only. <code>request_create</code> verifies the
  upload before the request exists, so <code>DOCUMENT_NOT_UPLOADED</code> means step 2 was skipped. One upload can be referenced by any
  number of requests, and its bytes count toward your workspace storage.
</p>

<h3 id="requests-domains">Custom domains</h3>

<p>
  Pass <code>domainId</code> to <code>request_create</code> to mint the link on one of the workspace's{' '}
  <a href="/branding-domains/custom-domains">custom domains</a>. The ids come from <code>formShareLink_list</code>, which returns them as{' '}
  <code>availableCustomDomains</code>. Omit it and the link takes whatever domain the form is already published under.
</p>

<h3 id="requests-reading">Reading results</h3>

<p>
  <code>request_get</code> returns the whole request. Once it is completed, <code>answers</code> holds the recipient's values keyed by field
  key, <code>display</code> the same keys as readable text, and <code>outcome</code> — approve, decline, or changes — is their verdict when
  the form has a <a href="/requests/overview">decision question</a>. A <code>callbackFailedAt</code> timestamp means delivery ran out of
  retries and nothing reached your endpoint; fix the receiver, then call <code>request_replayCallback</code>, which re-sends the original
  event id so your receiver dedupes instead of re-running. After the form's retention policy strips a request, <code>dataPurgedAt</code> is
  set and the answers are gone for good.
</p>

<p>
  <code>request_list</code> sweeps many at once, filtered by <code>status</code>, <code>outcome</code>, <code>externalId</code>, and{' '}
  <code>includeTest</code>. Page with <code>nextCursor</code>: a page can rarely come back with an empty <code>items</code> and{' '}
  <code>hasMore: true</code>, which is not the end of the list — pass the cursor back and keep going.
</p>

<h2 id="resources">Resources and prompts</h2>
<p>
  Every skill and tool catalog is also an MCP resource at <code>skill://&lt;name&gt;</code> — <code>skill://requests</code>,{' '}
  <code>skill://editor-inserts</code>. A client that supports <code>resources/list</code> can browse and read them without calling{' '}
  <code>load_skill</code> or <code>load_tools</code>. The server also serves four prompts on <code>prompts/list</code>:{' '}
  <code>identity</code>, <code>capabilities</code>, <code>data_tools</code>, and <code>editor_tools</code>.
</p>

<h2 id="confirmation">Tools that ask first</h2>
<p>
  Every tool carries the MCP hints <code>readOnlyHint</code> and <code>destructiveHint</code>, derived from its verb. These tools are marked
  destructive, because undoing them takes another call or is not possible: <code>form_delete</code>, <code>form_unpublish</code>,{' '}
  <code>workspaceFolder_delete</code>, <code>editor_deleteElement</code>, <code>translationLanguage_delete</code>, and{' '}
  <code>request_cancel</code>. Most clients ask the user before running them, but the prompt is the client's decision, so check its approval
  settings if you need a hard stop.
</p>

<h2 id="limitations">Limitations</h2>
<ul>
  <li>
    <strong>No binary uploads through a tool call.</strong> Images are set by URL: covers, logos, and image blocks accept{' '}
    <code>http(s)://</code> or <code>data:image</code> URIs. A per-request document is the exception: <code>document_create</code> returns
    an upload URL that a client with HTTP access can <code>PUT</code> the file to, as{' '}
    <a href="#requests-documents">Per-request documents</a> explains. To turn a PDF or screenshot into a form, use the{' '}
    <a href="/ai/ai-form-generation#files">built-in AI chat</a>.
  </li>
  <li>
    <strong>No workspace AI skills.</strong> <a href="/ai/ai-skills">Skills written in formbase</a> are only available in the built-in AI
    chat. The server's own skills (<code>load_skill</code>) are available over MCP.
  </li>
</ul>

<h2 id="api-token-clients">Connecting with an API token</h2>
<p>
  Most clients sign in with OAuth: add the URL with no header and follow <a href="/guides/ai-agents/connect">Connect an AI agent</a>. A
  client that cannot open a browser, such as a script, CI job, or headless agent, sends an <a href="/developers/api-tokens">API token</a> as
  a header instead.
</p>
<p>Claude Code, from the command line:</p>

```
claude mcp add --transport http formbase https://api.formbase.so/api/mcp \
  --header "Authorization: Bearer fb_YOUR_TOKEN"
```

<p>
  Or in a project's <code>.mcp.json</code>:
</p>

```
{
  "mcpServers": {
    "formbase": {
      "type": "http",
      "url": "https://api.formbase.so/api/mcp",
      "headers": {
        "Authorization": "Bearer fb_YOUR_TOKEN"
      }
    }
  }
}
```

<p>
  Cursor, in <code>.cursor/mcp.json</code> or <code>~/.cursor/mcp.json</code>:
</p>

```
{
  "mcpServers": {
    "formbase": {
      "url": "https://api.formbase.so/api/mcp",
      "headers": {
        "Authorization": "Bearer fb_YOUR_TOKEN"
      }
    }
  }
}
```

<p>
  To sign in with OAuth instead of a token, use{' '}
  <a href="https://cursor.com/install-mcp?name=formbase&config=eyJ1cmwiOiJodHRwczovL2FwaS5mb3JtYmFzZS5zby9hcGkvbWNwIn0=">Add to Cursor</a>.
  It adds the server URL with no header, and Cursor asks you to sign in to formbase.
</p>
<p>Other clients take the same URL and header; see their documentation for where.</p>

<h2 id="oauth">Using OAuth instead of API tokens</h2>
<p>
  A third-party app connecting on behalf of a user should use OAuth rather than ask for a pasted token. formbase is an OAuth 2.1
  authorization server with mandatory PKCE (S256) and opaque tokens — no JWTs, no implicit grant. Claude desktop, Claude Code, and the
  Claude.ai web connector discover all of it from the MCP endpoint, so pasting the URL with no header is enough: the 401 points at{' '}
  <code>/.well-known/oauth-protected-resource</code>, and the client takes it from there.
</p>
<p>The flow, for a client you are writing yourself:</p>
<ol>
  <li>
    <code>GET /.well-known/oauth-protected-resource</code>, then <code>GET /.well-known/oauth-authorization-server</code> for the endpoint
    URLs, scopes, and supported auth methods.
  </li>
  <li>
    <code>POST /oauth/register</code> with your <code>redirect_uris</code> (dynamic client registration, no credentials needed). You get a{' '}
    <code>client_id</code>, plus a <code>client_secret</code> if you asked for anything other than{' '}
    <code>token_endpoint_auth_method: "none"</code>. Redirect URIs must be HTTPS, or HTTP on <code>localhost</code>. Registration is capped
    at 20 per hour per IP.
  </li>
  <li>
    Send the user to <code>/oauth/authorize</code> with <code>response_type=code</code>, your <code>client_id</code>, the registered{' '}
    <code>redirect_uri</code>, <code>scope=mcp:read mcp:write offline_access</code>, <code>state</code>, and a <code>code_challenge</code>{' '}
    with <code>code_challenge_method=S256</code>. They sign in, pick one workspace, and authorize.
  </li>
  <li>
    Exchange the code at <code>POST /oauth/token</code> with <code>grant_type=authorization_code</code> and your <code>code_verifier</code>,
    within 60 seconds. Codes are single use.
  </li>
  <li>
    Call the MCP endpoint with <code>Authorization: Bearer fbo_...</code>. Access tokens last 1 hour; refresh tokens last 30 days and rotate
    on every use. Reusing a spent refresh token burns the whole chain, so store the newest one.
  </li>
</ol>
<p>
  <code>POST /oauth/revoke</code> (RFC 7009) revokes an access or refresh token. A user can also disconnect the whole app from{' '}
  <strong>Connected apps</strong> on the OAuth and API Keys page, which kills every token it holds for that workspace.
</p>

<p>If you do have a token in hand, it goes in the same place as an API key:</p>

```
{
  "mcpServers": {
    "formbase": {
      "type": "http",
      "url": "https://api.formbase.so/api/mcp",
      "headers": {
        "Authorization": "Bearer fbo_USER_OAUTH_TOKEN"
      }
    }
  }
}
```

<h3 id="connected-apps">Connected apps</h3>
<p>
  Every OAuth connection is listed under <strong>Connected apps</strong> on the <strong>OAuth and API Keys</strong> page, with when it was
  connected and last used. Connections are personal: only the user who authorized one sees it, and workspace admins cannot view or revoke
  another member's. Disconnecting takes effect at once. A user who leaves or is removed from the workspace loses all their connections to
  it.
</p>

<h2 id="next-steps">Next steps</h2>
<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Connect an AI agent](/guides/ai-agents/connect) — Step-by-step setup in any MCP client
  - [Webhooks reference](/developers/webhooks-reference) — Payload schema and signing
  - [REST API](/developers/rest-api) — API methods for programmatic access
  - [Plans & pricing](/subscription-billing/plans-pricing) — Compare plan API access and limits
</div>
