DocsDevelopers

The MCP server

One option turns an app into an MCP server, so a client's own Claude or ChatGPT works in the app as that user, under the same rules, hooks and journal as the UI.

createApp({ mcp: {} }) serves the app as an MCP server at /mcp. A client connects the AI they already use and works in the app AS THEMSELVES. Every tool call goes through the same route, rules, hooks and journal as a click in the UI. The AI is theirs and they pay for it. The option is off by default.

How a client connects their AI, step by step: Connect your agent.

Turn it on#

const app = createApp({
    schema,
    meta: { appURL: 'https://orders.example.com' },   // the public URL: the MCP resource and the OAuth issuer derive from it
    mcp: {
        hide: ['audit_log', 'users.delete'],
        instructions: 'Orders of a shop. Amounts are in euros.',
    },
})

// an action of the app: the route as usual, plus mcpTool(...) among its middlewares
app.route('POST', '/api/orders/:order/send-invoice', sendInvoice, requireAuth(), mcpTool({
    name: 'send_invoice',
    description: 'Mail the invoice of one order to its client.',
}))
optiondefaultwhat
url<meta.appURL>/mcpthe public URL of the endpoint: the audience of every token
authevery auth collection except _superusersthe collections whose records can connect
hide[]a collection ('audit_log') or one action of it ('users.delete'); actions are list view create update delete
superusersfalsea superuser's token works on /mcp only when true
oauthtruethe built in authorization server; false keeps bearer tokens only
instructionsnoneone paragraph for the model: what the app is, its units. Sent after the primer
origins[]browser origins allowed beside the app's own

Set meta.appURL to the app's public address. The OAuth metadata and the token audience are built from it.

The tools#

Every app offers the same seven tools, whatever the size of its schema. The model reads the data's shape from schema, then works on any collection by name:

toolargumentsrequest
schemanonenone: the collections this caller may use, as text
listcollection filter sort page perPage fields expandGET /api/collections/:collection/records
getcollection id fields expandGET /api/collections/:collection/records/:id
createcollection dataPOST /api/collections/:collection/records
updatecollection id dataPATCH /api/collections/:collection/records/:id
deletecollection idDELETE /api/collections/:collection/records/:id
batchrequests: [{ action, collection, id?, data? }], at most 50POST /api/batch: one transaction, all or none

schema answers one line per collection: its kind, the actions the caller may run, and its fields with their types:

orders: list get create update | order_no text!, client →clients!, status select(open|done), total int 0.., due date, created auto, updated auto
users (auth): list get | email email, name text, role select(staff|boss)

! is required, [] many, →clients a relation (its value is a record id), "…" the field's help, auto a date the server sets.

  • A caller sees a collection when it is not a system one, not hidden, and has at least one action whose rule is not null. A view offers list and get only.
  • A collection the caller cannot see is refused by every tool with the same message as a collection that does not exist. A batch checks each of its requests first and runs none when one fails.
  • Hidden fields, passwords, tokenKey and the rules themselves never appear. A file field shows as file: files go through the app's UI.
  • schema, list and get carry readOnlyHint; delete and batch carry destructiveHint.
  • The server's instructions start with a short primer: call schema first, the filter syntax (Rules and filters), sort, fields, expand, how data takes relations and dates, how a refusal reads. The app's instructions follow it.

The app's own actions#

mcpTool({ name, description, input?, readOnly?, destructive? }) among a route's middlewares offers that route as a tool. Only the routes marked this way become tools.

  • The name is 1 to 64 of A-Z a-z 0-9 _ -, not one of the seven, and unique among the app's routes. A bad or repeated name, or a route with a * wildcard, throws at app.route.
  • Each :name in the path is a required argument. The other arguments become the query string on GET and DELETE, and the JSON body otherwise.
  • The route is its own access control, as for the UI: requireAuth(), or the checks in its handler.

One code path#

A tool call is an internal request through the app's own router, app.dispatch. The route, its middlewares, the request hooks, the rule, the after success hooks and the journal all run, exactly as for the UI. The MCP code never touches the records service directly.

  • The request carries the caller's record, the IP and user agent of the MCP request, and @request.context = "mcp".
  • A rule can refuse the AI something the UI allows:
deleteRule: 'owner = @request.auth.id && @request.context != "mcp"',
  • The journal writes context = mcp on every row an AI caused.
  • A refused call (a rule, a 404, a validation error) is a tool result with isError: true and the route's { status, message, data }. The model reads why.

Auth#

Every request to /mcp needs Authorization: Bearer <token>. Without one it gets 401 with a WWW-Authenticate header that starts the OAuth flow.

  • A bearer token: the token of auth-with-password works as is, for scripts and Claude Code. It lives as long as the session token (12 hours by default).
claude mcp add --transport http orders https://orders.example.com/mcp --header "Authorization: Bearer <token>"
  • OAuth 2.1, for connectors such as claude.ai, Claude desktop and ChatGPT (built to the MCP authorization spec and checked with the official MCP clients; no such connector has been tried against a live app yet): dynamic client registration, PKCE S256, and a consent page served by the app on its own origin. A signed in user sees "Allow <client> to act as <email>?". A signed out user signs in there with their password.
  • An MCP access token lives 1 hour and works on /mcp only. A refresh token lives 30 days and rotates at each use. A password or email change kills both.

The routes, the token kinds and the error codes: MCP protocol and OAuth.

Safety#

  • Writes are rate limited per signed in record: 60 per 60 seconds by default, the rateLimits rule labelled mcp:write. create, update, delete and every app tool not marked readOnly count one per call; a batch counts one per request inside it. Past the limit, the tool answers 429 as an error result.
  • A superuser never reaches /mcp unless superusers: true.
  • A request with a foreign Origin header gets 403, against DNS rebinding.
  • Any request a tool makes stays on that tool's own route. A batch made over MCP keeps the mcp context, so the journal and the rules see it.
  • A client revokes a token at POST /oauth/revoke (RFC 7009, listed in the metadata), and the access tokens of that grant stop at once. A registration unused for 30 days is deleted.

Not here yet#

This page as markdown