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.',
}))
| option | default | what |
|---|---|---|
url | <meta.appURL>/mcp | the public URL of the endpoint: the audience of every token |
auth | every auth collection except _superusers | the collections whose records can connect |
hide | [] | a collection ('audit_log') or one action of it ('users.delete'); actions are list view create update delete |
superusers | false | a superuser's token works on /mcp only when true |
oauth | true | the built in authorization server; false keeps bearer tokens only |
instructions | none | one 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:
| tool | arguments | request |
|---|---|---|
schema | none | none: the collections this caller may use, as text |
list | collection filter sort page perPage fields expand | GET /api/collections/:collection/records |
get | collection id fields expand | GET /api/collections/:collection/records/:id |
create | collection data | POST /api/collections/:collection/records |
update | collection id data | PATCH /api/collections/:collection/records/:id |
delete | collection id | DELETE /api/collections/:collection/records/:id |
batch | requests: [{ action, collection, id?, data? }], at most 50 | POST /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 offerslistandgetonly. - A collection the caller cannot see is refused by every tool with the same message as a collection that does not exist. A
batchchecks each of its requests first and runs none when one fails. - Hidden fields, passwords,
tokenKeyand the rules themselves never appear. A file field shows asfile: files go through the app's UI. schema,listandgetcarryreadOnlyHint;deleteandbatchcarrydestructiveHint.- The server's instructions start with a short primer: call
schemafirst, the filter syntax (Rules and filters),sort,fields,expand, howdatatakes relations and dates, how a refusal reads. The app'sinstructionsfollow 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 atapp.route. - Each
:namein 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 = mcpon every row an AI caused. - A refused call (a rule, a 404, a validation error) is a tool result with
isError: trueand 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
tokenofauth-with-passwordworks 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
/mcponly. 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
rateLimitsrule labelledmcp:write.create,update,deleteand every app tool not markedreadOnlycount one per call; abatchcounts one per request inside it. Past the limit, the tool answers 429 as an error result. - A superuser never reaches
/mcpunlesssuperusers: true. - A request with a foreign
Originheader gets 403, against DNS rebinding. - Any request a tool makes stays on that tool's own route. A batch made over MCP keeps the
mcpcontext, 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.