Skip to content

MCP server

Shipped

Your Turbobase instance can expose a Model Context Protocol (MCP) endpoint, so an MCP client — Claude Desktop, Cursor, or anything else that speaks MCP — can inspect your canonical data without needing Postgres credentials.

The MCP server is off until an administrator turns it on at /admin/settings. While disabled, the endpoint responds as disabled to every caller, so a probe can’t tell “disabled” from “wrong credentials.”

Two credentials reach this endpoint, and there is no shared, all-access token for either.

  • A per-user OAuth connection. A person connects their own client and signs in; the validated token is bridged to a Turbobase session for that caller. Reads run as them.
  • A personal access token carrying mcp:data. A script, an agent or anything else unattended presents the token as a bearer. Reads run as the administrator who minted it, and it can change data as well if it also carries mcp:write. A token carrying either permission lasts 7 days by default rather than the 90 an ordinary PAT gets — long enough for a job that spans a working week, short enough that one left in a project file does not stay useful for a quarter. Set a longer lifetime at mint if you need one. See access tokens.

Whichever arrives, the endpoint bridges it to the same short-lived Turbobase session, which carries that person’s role, capabilities, and scopes. That session is minted inside the request and never leaves it — no response body carries it, nothing sets a cookie, and it expires within minutes however long the credential behind it lives. Every read and every write then goes through the same row-level security the app uses: an administrator gets the full canonical set, a standard user gets only their in-scope rows, and an out-of-scope record reads as simply “not found” — no existence leak. There is no path that bypasses RLS.

Read and write permissions are separate, and both are the user’s, not the client’s: a client connected by someone who can view orders but not edit them can read orders and nothing more.

Both credentials can change data, and a token needs one more permission to. A person’s own connection carries their whole authority, so what it may change is decided by their own permissions and by your instance’s write setting. A token carries only what an administrator ticked for it: reading takes mcp:data, and changing anything takes mcp:write as well — a second box, granted alongside the read one and never implied by it. Without it the write tools below are not offered to the token and refuse it if named anyway, and turning on your instance’s write setting does not change that.

Your instance’s setting still outranks the token. mcp:write says this credential is allowed to change data; Allow MCP clients to change data says this instance is. Both have to be true, and the two refusals stay distinguishable — so a client that cannot write is told which of the two to go and fix.

These two permissions are about this endpoint and nothing else. A token may also carry rest:read and rest:write, which let a program reach your data over the ordinary web interface — see access tokens. Neither pair implies the other: a token connected here is not thereby allowed on that interface, and a token allowed there is still refused here without mcp:data.

A token that lacks a permission it needs is refused with a plain message naming that permission — not a sign-in challenge. Its credential is fine; the permission is missing, and an administrator can add it on the token’s own row at /settings/tokens without re-issuing it, so nothing has to be re-pasted wherever the token is stored.

Clients register themselves automatically — there is no OAuth Client ID to copy or paste. Each Turbobase instance fronts its sign-in with a per-instance OAuth bridge that implements Dynamic Client Registration (RFC 7591) and Client ID Metadata Documents (CIMD), so any MCP client — Claude, Cursor, VS Code, a raw MCP SDK, or an autonomous agent — registers its own callback (a loopback port, or an HTTPS callback — plaintext http:// on anything but localhost is refused) on first use and opens the sign-in page. Add the endpoint shown on the /admin/settings Integrations card to your client and connect; sign-in then happens on your own Turbobase sign-in page — the same credentials you use in the app. No operator action is needed to onboard a new client type.

You approve each client once. Because registration is open by design, signing in is not on its own an authorization: the first time a given client asks, Turbobase shows you an approval screen naming the client, the exact address your authorization would be sent to, and what it is asking for. Nothing is issued until you approve it.

That screen is also where you decide whether the client may change anything. It carries one permission — let this client create, change and delete records — and it starts unticked, so approving without touching it connects a client that can read your records and nothing more. Reading is granted either way. You can change your mind at any time from Account → Connected apps, which says of every connection whether it may change records and lets you grant or take that back one client at a time; the next call the client makes sees the new answer, with nothing to reconnect. The permission is yours and applies to your connection only — the same client connected by a colleague is a separate decision of theirs. After that the same client connects silently, and every client you have approved is listed under Account → Connected apps, where you can revoke any one of them individually. That tab is not only MCP: the same OAuth server signs people in to native apps your team builds, and those appear beside your MCP clients, each row saying which it is. A revoked client cannot renew the access it already holds — its next token refresh is refused — and it faces the approval screen again before it can obtain a new token. Every token request a client makes names the client it is for (its client_id), which is what ties the request to your approval, and a refresh token is usable only by the client it was issued to — presented under any other client’s id, it is refused.

A client’s registration is provisional until someone signs in through it: one that never completes a sign-in lapses within the hour, and simply registers again the next time it connects. Approval is per person — a colleague’s approval of the same client does not carry over to you.

A connection lasts thirty days. Once you have signed in, the client renews itself quietly in the background and you are not asked again until the thirty days are up — however often, or however little, you use it. Then you sign in once more. Revoking the client under Account → Connected apps ends it sooner, as above.

If an approval screen appears when you did not just connect a client — particularly one flagged as never seen on this instance before — close the page and tell your administrator. That is what a link sent by someone else looks like.

Every instance exposes these read tools:

  • list_record_types — every record type your instance knows about, including your custom tables, with source attribution and what may be done with each one: whether it is read-only or writable, whether write-back is switched on for that system right now, which operations are actually reachable, one line on what the record is for, and the name of the prompt holding the full account of it. That last group is what tells two records apart when their names do not: on a Slack-connected instance message is the channel history your instance has read and channel_message is the message it posts. Metadata only, no row data.
  • list_records — list rows of a record type (capped at 100 per call), through the caller’s RLS.
  • search_fields — find which record type and column holds a piece of data, by concept: “exchange rate”, “credit limit”, “due date”. Search this first when you know what you want but not where it lives — the other tools all assume you already know the record type’s name. Schema metadata only, never row data.
  • get_record — fetch a single canonical record by record type and source-side id.
  • get_write_state — what became of a change to one record. A write through this server is saved here and queued, so this is the tool that says where it got to: synced once your source system took it, along with the id that system minted for it; pending while it is still on its way; needs_input or blocked if something refused it, with the reason written down at the minute it happened — the field that was missing, the source’s own sentence, the list of fields to fix. Address the record by the pg_row_id a write returned, which is the only key a change that has not reached the source yet has. Pass wait_ms and the call holds for up to twenty seconds waiting for the verdict, so a client can ask once instead of checking on a timer; if the wait runs out the answer still comes back, saying so. It is offered whether or not writes are switched on, because a stuck record outlives the setting that produced it, and it answers only for records the caller could already read.
  • search_documentation — search your published knowledge base — processes, policies, playbooks. Returns matching documents with a snippet. Questions about how your organisation works route here, as opposed to list_records, which reads transactional business records.
  • read_document — read one published knowledge-base document in full by its slug. The published release only: unpublished drafts are never returned. A slug from a search_turbobase_docs hit belongs to read_turbobase_doc instead — this tool reads what your organisation wrote.
  • search_turbobase_docs — search Turbobase’s own documentation: how hooks, conduits, apps, the source sync, the CLI and this server work. Questions about the platform route here — “how do I read the mirror from an app”, “what does a hook receive” — as opposed to search_documentation, which searches your own written processes and policies. The pages are the ones your instance’s release shipped, so the answer always describes the version you are actually running, and it says which version that is.
  • read_turbobase_doc — read one page of Turbobase’s documentation in full by its slug, from a search_turbobase_docs hit. A slug that is not one of our pages comes back as found: false rather than as an error, and a slug from a search_documentation hit belongs to read_document instead.
  • batch_read — run up to ten of the above in one call and get the results back in order. Useful when a client already knows the handful of things it needs and would otherwise make ten round-trips. One item failing leaves the others intact.

One name, several systems — and the combined list

Section titled “One name, several systems — and the combined list”

Two things to know before reading a record type whose kind of row more than one of your systems holds.

Your systems each have their own word for a customer, and asking for one of those words gets that system’s list. customer, zoho_crm:account and getsales:company are three names for the same kind of row, and a plain list_records { "record_type": "customer" } answers with whichever connected system publishes that exact name. That is now said in the answer: applied.source_filter names the system the read narrowed to, and warnings names your other systems’ words for the same thing. Nothing is hidden and nothing is refused — the count is just one system’s, which is the part that used to be invisible.

Where several of your systems spell a record type the same way — contact, say — one read already covers all of them: applied.source_filter is null, the rows come back from every one of them, and the answer says which systems it spanned. There is nothing missing from that read, so it carries no “other words” warning.

For one list across all of them, read the identity record type. Turbobase already consolidates these rows into a single identity per real-world thing (Atlas), and that identity is a record type of its own:

list_records { "record_type": "turbobase:identity_customer", "page_size": 100 }

There is one per bucket — turbobase:identity_customer, …_vendor, …_contact, …_employee, …_partner, …_lead, …_item — each read under the capability for that bucket, each pageable with page.next_cursor like any other record type, and each read-only, because Atlas derives the rows. get_record does not take them: an identity is addressed by its own id rather than by a source-side id, so read it with list_records and match on id. Whenever the warning above fires it names the right one for what you asked for, so a client does not have to know the list in advance.

These read across your whole instance — your connected account, or the change history of every record in it — rather than one person’s rows, so there is nothing to narrow person by person, and that is why they are administrator-only. A client signed in as anyone else is not offered them at all: they are absent from its tool list, not present and refusing, so a non-admin’s assistant never suggests something it cannot do.

  • search_catalog — search your customization catalog by free text: scripts, custom records, fields, lists, workflows, roles, saved searches, installed bundles. It matches ids, names and the plain-English explanation Turbobase generates for each one, so you can ask what you actually want to know — “which script handles rebates?” — rather than needing the id first. Each hit says whether the object is yours to change.

  • explain_object — one catalog object in depth: its captured definition or script source, what it is for in plain English, and whether this account can change it. Address it with the kind and externalId a search_catalog hit reports.

  • source_script_log — your source account’s own script execution log, for the minute a write failed in. Reach for it when a write is refused for a reason your payload does not explain. Pass until to read an earlier hour — up to about 30 days back — such as the minutes around a save that did not reach your instance in real time.

  • source_inbound_deliveries — what one of your connected systems actually sent to Turbobase, and what Turbobase did with each delivery. Reach for it when a change you made in a connected system has not appeared here: it is the only place that tells “that system never sent it” apart from “we received it and turned it away” apart from “we dropped it on purpose”. Ask for a source, and optionally one connection_id, one record_type, a since_minutes window (default 1440 — twenty-four hours — and clamped rather than refused) and a limit (default 100, maximum 200). The answer has three parts, and the second two are the point:

    • entries — one per delivery, newest first: when it arrived, which connection and record type it was about, which record (record_ref), and its status — accepted, failed, ignored or refused. failed means the delivery got in and the work it started then failed, so a delivery that did not land is never reported as accepted. reason says exactly which check ended it.
    • registration — whether that system is still subscribed to send you anything (registered, which is true, false, or null when Turbobase cannot tell), the address Turbobase expects it to post to, and the last delivery seen at all, outside your window. An empty list with registered: false is a subscription to re-create; an empty list with a delivery an hour ago is a different problem entirely.
    • window — what was actually read. Read truncated before concluding anything: when it is true you are looking at a capped slice, so “nothing here” is not “nothing happened” — narrow by record type or shorten the window and ask again.

    No value from a delivery’s contents is kept, so this tells you whether a change arrived and never what was in it.

  • source_late_arrivals — the changes from a connected system that reached Turbobase late: picked up by the scheduled catch-up because the real-time path never delivered them. It reads the same record as the Late arrivals screen. Reach for it to find which records real time misses — one record type, one connection, or one record by external_id — and how late they were. Ask for a source, and optionally connection_id, record_type, external_id, path (scheduled, the default, lists the late ones; all lists both), a since_minutes window (default 1440, at most 90 days) and a limit (default 100, maximum 500). The answer has three parts:

    • entries — the late changes, newest first: when each was applied, which record, what the change did, the system’s own modified time and lag_seconds. An empty lag_seconds means the system gave no time to measure from — a reconciled deletion, or a record that carries only a date — not that it arrived on time.
    • summary — per record type, how many changes arrived in real time and how many late over the same window. Read it first: it shows whether the gap is one record type or everything.
    • window — what was actually read. Read truncated before concluding anything, exactly as for the delivery tool.

    To see why one record was missed, read it with get_record (who last changed it, and on which form) and check source_inbound_deliveries for whether a delivery for it ever arrived.

  • source_relationships — the linked record pairs your instance has found: which table holds the lines, which holds the parent, and the field on the line that points at it — together with which of the two “re-read” behaviours is switched on for each pair, and when it was switched on. Optionally ask for one source; omit it to list every connected system. Each pair carries both markers as a time or as nothing: nothing means that re-read is off, and a time says when it was switched on, which is the answer to “did my change take effect”. The app does not list the pairs: a record on Data → Records carries a re-read mark when one of its pairs has a re-read switched on, and this tool is where the three names and the time live.

  • source_relationship_refresh — switch one of a pair’s two re-reads on or off. Address the pair with child_table, link_key and parent_table, exactly as source_relationships lists them, then say which behaviour (marker: "children" or marker: "parent") and which way (enabled: true or false). A pair your instance has not found is refused, and the refusal names what is declared on that line table — nothing is ever created here. The answer says changed: false when the pair was already in that state, and the original time is left alone rather than re-stamped.

    The two behaviours are independent, and which one you want depends on which record is arriving late:

    • children — re-read the lines when their parent is saved. Some systems commit a parent’s lines as part of the parent’s own save and fire no event on the lines, so the parent arrives in seconds and its lines wait for the scheduled catch-up.
    • parent — re-read the parent when one of its lines is saved. The mirror image: saving a line makes the system rewrite totals on the parent, silently.

    Each switched-on pair has a running cost — one query to the connected system per parent save, plus one re-read per line — so switch on the pairs that are actually arriving late (source_late_arrivals says which) rather than all of them.

  • list_audit_events — search your audit log for who changed a record, when, and which values moved. Reach for it whenever the question is “who did this”: a change Turbobase pushes back reaches your source system as the one integration account, so that system’s own history names nobody and this is where the person’s identity is. Filter by actor — an email works, because the trail snapshots it beside the raw id — by record_type plus external_id or pg_row_id for one record, by action (a trailing dot means the whole family, so record. is every row change), by changed_field, and by a since / until window. Each row carries the changed fields only, with the previous and new value of each, never the two whole records. Pages newest first, 25 a page and at most 100; pass page.next_cursor back as cursor for the next one.

    changed_field is matched after the read rather than by an index, so three things follow: it needs one of the other filters beside it, a page can come back short, and page.post_filtered is how you tell a short page from the end of the set.

    There is one other way a page is not the end. A bulk write stamps every row it touches with the same instant, and a band of more than 500 rows at one instant is deeper than a single read reaches — when that happens the last page carries page.seek_capped, which means “narrow the window to see the rest”, never “that is everything”.

  • get_record_history — one record’s timeline, newest first, with what became of each change beside it. Each entry is an audited change — who, when, which fields moved — plus sync: the outbound event it produced, its state, and the moment it reached your source system. So an entry reads “this person set status Pending → Delivered at 18:09, and it reached the source at 18:09:23”. A change made in Turbobase that produced no outbound event is still an entry and says so in sync_note, because that gap is usually the thing being looked for; a change that arrived from your source system is marked inbound. It returns the newest limit changes (100 by default); when the record has more, page.next_cursor pages back through the older ones, as far as the trail goes — at least a year. since and until narrow it to a stretch of time. Address the record by record_type plus external_id, or by pg_row_id for a row your source has never minted an id for.

    Reading the trail is itself recorded on your audit log, under mcp.audit_log.read and mcp.record_history.read — who read who changed what is as answerable as the change.

Catalog search is also on the authoring endpoint, reached with a personal access token. Both doors answer the same question the same way; an administrator connected here needs no token to ask it.

The change requests on Development → Proposals can be worked through from a client too, so an administrator can read one, discuss it and act on it without opening the app. Like the Proposals page itself, these are administrator-only and absent from anyone else’s tool list. Two tools read:

  • list_proposals — the proposals in one of the page’s three tabs: active (still in flight, the default), completed (signed off or beyond) or archived. Each hit carries its short number, title, status, labels, epics and evidence count, and can be narrowed by label, epic, status or title.
  • get_proposal — one proposal in full, addressed by its id or its short number: the text, the evidence, the timeline of every comment and change with who made it, and the revision history. It also lists which of the actions below are allowed on it right now.

Eight more act on a proposal. They change data, so they are offered only where client changes are switched on and, for an access token, only when it carries mcp:write. Each writes the same timeline entry the page’s own button writes, under your name:

  • comment_proposal — add a comment to its timeline.
  • rename_proposal — change its title.
  • revise_proposal — write a new version of its text; the old one is kept in the history.
  • sign_off_proposal — mark a draft complete. This freezes it and, when it carries a target, creates its Solution for review. Nothing is applied.
  • archive_proposal — hide it from the list, or bring it back.
  • delete_proposal — delete an archived proposal permanently, only with an explicit confirmation, and never one whose data-manipulation run wrote to your source.
  • label_proposal — add or remove a label, or create a new one and add it.
  • group_proposal — group it under an epic, ungroup it, or create a new epic for it.

A signed-off proposal is frozen here exactly as it is on the page: renaming or revising it is refused, and the change goes to its Solution instead.

Two things have to be true, and they belong to two different people.

The first is the instance’s: a new instance’s MCP server is read-only, and stays that way until an administrator turns on Allow MCP clients to change data on the same /admin/settings card as the server itself. It is a deliberately separate switch from the server itself: whether an external LLM may read your mirror and whether it may change your ERP are different decisions, and wanting the first shouldn’t commit you to the second. It is a ceiling — while it is off, nothing can change data, whatever anybody has granted.

The second is yours, on the connection the request arrives on. Each client you connect carries its own permission to change records, granted by you on the approval screen or afterwards from Account → Connected apps, and no connection has it by default. So turning the instance setting on does not arm every client everybody has ever connected: it only makes it possible for a person to grant one of their own. An access token is the same question one layer along, answered on the token’s own row — a token writes if it carries mcp:write, and the per-connection permission does not apply to it.

The two refusals read differently on purpose, because they are fixed by different people: one names the instance setting and sends you to an administrator, the other names the client’s own missing permission and sends you to your account page.

While either is missing, the write tools don’t merely refuse — they are not offered at all, so a connected client never sees them. Once both hold, the caller gets two more tools:

  • write_record — create or update one record. Whether it creates or updates is decided by the keys: pass a record’s id and it edits that record; pass none and it creates a new one. Records with line items (a sales order and its lines) are written together in one call.
  • delete_record — delete one record, addressed by its id. There is no delete-by-filter and no bulk delete, deliberately: a client has to find the record first and name it. Where the delete goes is decided by the record’s own system, not by the one the client signed in against.

A write from an MCP client is not a side door. It runs the identical path a write from your app runs, in the same order:

  1. Your business rules. The same hooks that validate an in-app write run here, synchronously. A rejection stops the write and comes back to the client as a readable explanation it can act on.
  2. Your write guardrails. The same rules on your Guardrails page apply, including the fact that deleting is its own permission — allowing a record type to be edited never implies allowing it to be deleted.
  3. The caller’s own permissions. Row-level security decides, per row, exactly as it does for that person in the app. A client can only change what its signed-in user could already change; being able to see a record does not grant changing it. A write arriving over an access token runs as the administrator who minted that token, so it can never exceed what that person could do in the browser either.
  4. The normal sync queue. The change is saved in Turbobase and queued for your source system, the same as any other change made in Turbobase.

A write or a delete may name any connected system. On an instance with several systems connected, write_record takes a source-qualified record type — record_type: "zoho_crm:account" — and the change is routed to that system whichever one the client’s session happens to have signed in against. delete_record is no longer limited to the session’s own system either: a delete is routed by the record’s own system, read off the record rather than off the sign-in. So deleting a record belonging to a system whose upstream delete is not implemented yet is refused at once, naming that system — and a record that can be deleted is deleted whichever system the client signed in against. When the id you pass matches records in more than one connected system (or in two accounts of one system), the delete is refused rather than applied to one of them: address the one you mean by its pg_row_id. And where two connected systems spell one record type the same way — account means a GL account in NetSuite and a customer in Salesforce and in Zoho CRM — name the system, either as record_type: "netsuite:account" or with source_system, and the delete resolves. On a delete that name is a qualifier rather than an instruction: it says which record is meant, and the record’s own system still decides where the delete is sent.

That last point is worth repeating to anyone reading a client’s summary: a successful write is not yet in Oracle NetSuite. It is in Turbobase and on its way. The record’s write_status tells you where it got to: pending while queued, synced once the source accepted it, needs_input or blocked if something refused it, and awaiting_approval if your own guardrails are holding it for somebody to approve before it is sent. The tool descriptions tell the client this too, so a well-behaved one will check before reporting back — and get_write_state is how it checks, on the pg_row_id the write itself returned. It answers for every one of those states, success included, and it takes a wait_ms so the client can ask once and be told the outcome rather than reporting a queued write as a finished one.

A write that is missing something mandatory is refused on the call, not afterwards. Where a connector can decide the question from the payload alone, list_record_types publishes what a create needs and the write boundary refuses on exactly that list — so a client is told what to send in the same answer that tells it the write is allowed, and an incomplete one comes straight back naming the field rather than being accepted and parked.

Every mutation attempt writes a row to your audit log — the successes and the refusals, including the ones that stop before any write is attempted: a write on an instance where MCP writes are off, a record type that is read-only or unknown, a client with no signed-in identity. So “a guardrail stopped it”, “the instance is read-only” and “nobody tried” are three different things in the log. Each row records who (the signed-in user, not “an MCP client”), which tool, which record type and record, what the outcome was, and which fields were touched by name. It also records which credential arrived — a person’s own connection, or an access token — and which one: for a token, which token; for a connection, which of that person’s connected clients. So an unattended change is distinguishable from a human one, and whichever credential made a surprising change can be found and taken back without disturbing the others that person uses. The who is unchanged either way: it is the person, which is what keeps your guardrail rules matching. Field values are not copied into the log; they are already on the record itself. Filter for them on the Compliance → Audit log page with the MCP client changes filter.

If a call fails for a reason that has nothing to do with the request — an internal error on the instance — the client is told only that, together with a correlation id. The cause is in the instance log under that id for an administrator to look up; it is never echoed to the client.

Your instance meters this endpoint itself, on top of the edge rate limit that fronts everything else it serves. The allowance is 2,000 requests per minute, which is sized so a bulk load — well over a thousand rows, one row per call — finishes inside a single window rather than being paced against a guess.

Four things decide how that budget behaves:

  • Reads and writes come out of the same budget. One counter covers the whole endpoint, so the list_record_types and list_records calls a client makes before an import spend from the same allowance the import’s writes do.
  • It is counted per instance process, not per connected client. A client does not have the budget to itself — every caller reaching the same process shares it — so pace against the numbers each response returns rather than assuming the full allowance is yours.
  • One call is one request, whatever it carries. A batch_read bundling ten reads costs one, and a write_record saving a sales order together with its lines costs one. Bundling is the cheapest way to fit a lot of work into a window.
  • The window does not re-arm when you are refused. Retrying while over budget is neither punished nor useful; it simply fails again until the window turns over.

Every response tells you where you are. Each reply carries the budget, so a client can pace against live numbers instead of a figure copied out of this page:

X-RateLimit-Limit: 2000
X-RateLimit-Remaining: 418
X-RateLimit-Reset: 37

X-RateLimit-Limit is the allowance, X-RateLimit-Remaining is what is left in the window you are in, and X-RateLimit-Reset is the whole seconds until it refills.

What a refusal looks like. Go past the allowance and the endpoint answers 429. That response carries Retry-After, in whole seconds and always equal to X-RateLimit-Reset, and repeats the same numbers in the body — because a client talking to this endpoint through an MCP library is handed a message rather than an HTTP exchange, and never sees a response header at all:

{
"jsonrpc": "2.0",
"id": null,
"error": {
"code": -32000,
"message": "Rate limit exceeded: this endpoint allows 2000 requests per minute, shared by reads and writes and counted per instance process. Retry in 34 seconds — retrying sooner does not extend the window.",
"data": { "limit": 2000, "remaining": 0, "reset_seconds": 34 }
}
}

error.code is -32000 for being throttled and nothing else, so a client can tell it apart from the endpoint being switched off without reading the message. Wait reset_seconds and carry on. Nothing before the refusal is rolled back: the calls that were answered are saved, so a resumed import should pick up from the last row that succeeded rather than starting again.

When your instance is momentarily busy or being updated

Section titled “When your instance is momentarily busy or being updated”

A read can also be declined for a reason the caller did nothing to cause. There are two of those, and they answer the same way:

  • Your instance’s data layer had no spare connection free at that instant.
  • Your instance is being updated — a short maintenance window, or the service answering reads restarting, which happens a few times a day and lasts a moment.

Your instance absorbs the brief version of either by itself: it waits a moment and asks again, and the caller gets its rows with nothing to see. A momentary restart therefore never reaches you at all. If the condition lasts longer than that, the call comes back as a refusal of its own:

{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32001,
"message": "the instance was momentarily too busy to serve this read — nothing was refused and nothing changed. The call is safe to repeat: wait a moment and send it again. If it keeps failing, give an administrator correlation id 3f0a9c21b7d4e85a.",
"data": { "retryable": true }
}
}

error.code is -32001 for those two conditions and nothing else; the message says which one you met. The call is safe to repeat either way — nothing was refused, nothing was written, and no partial result was returned; wait a moment and send the same call again. That is what separates it from -32603, the generic internal error, which means something inside the instance went wrong and a repeat will not help: take the correlation id out of that message and give it to an administrator instead.

Inside a batch_read, an item declined this way carries the same code on its own in-band error, so the rest of the batch still answers and only that item needs repeating.

A tool call gets eight seconds. Past that your instance stops waiting for its own handler and answers, rather than holding the connection open until the front door closes it with nothing on it:

{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32002,
"message": "`list_records` timed out after 8s — nothing was refused and nothing changed. The instance is answering slowly: wait a moment and send it again, or ask for less in one call. If it keeps failing, give an administrator correlation id 3f0a9c21b7d4e85a.",
"data": { "retryable": true }
}
}

error.code is -32002 for this and nothing else, and it is deliberately not -32001: the remedy is wider. Repeating the same call is one answer, and asking for less is the other — a smaller page_size, a tighter filter, fewer items in one batch_read. Nothing was written and no partial result was returned.

The same operator-curated skills that ground Pilot, your AI assistant, and Turbobase’s other AI surfaces are exposed to MCP clients as prompts: prompts/list shows what’s available, prompts/get pulls one into the client’s context. Two kinds:

  • Connector skills ship with each source’s connector (record anatomy, domain playbooks) and appear automatically — but only while their source is connected. Disconnect the source and the prompt disappears. A skill can opt out of a surface with surfaces: frontmatter (e.g. surfaces: pilot keeps a Pilot-only skill off MCP entirely).
  • Admin-authored skills (managed at /admin/skills) are opt-in: tick MCP server (external clients) on a skill to expose it. The default is off — authoring a skill for Pilot never silently shows it to external clients. See Pilot for the wider skills surface these per-skill toggles control.

A skill’s audience still applies through the caller’s bridged session: an admin-only skill is invisible to a standard user’s client — not merely unlisted but unfetchable by name, so there’s no existence leak. Toggles take effect immediately; no restart.

Three resources give a client the lay of the land without burning tool calls:

  • catalog:/// — the record catalog: every record type the caller can read, with backing table and column metadata. Capability-filtered per caller, like everything else on this server.
  • recipes:/// — intent recipes (the columns, stats, and actions that matter for a record type + intent), filtered to your connected sources. A recipe for a source you don’t have never appears, and a surviving recipe’s column/action projections drop the cross-source parts you can’t use.
  • docs:/// — a link map of documentation URLs. Links only — no doc bodies are proxied here, because the text itself is served by the search_turbobase_docs and read_turbobase_doc tools above, which need no web access at all.

At initialize the server hands the client an instructions block built for that caller: the connected sources, the record types their role can actually read, the available prompts and recipe intents, and links into these docs. The list_records and get_record tool descriptions likewise enumerate the live record types, so a client knows what it can ask for before its first call. Two callers with different roles get different instructions — the grounding is as RLS-scoped as the data.

What may be changed is one answer, not two. The block’s “Records you can CHANGE” line names exactly the pairs list_record_types reports writable_now: true for — one derivation serving both, so the handshake and the listing cannot contradict each other about the same instance. The line is what can be changed at all; whether a system named on it is connected is the “Connected sources” line above it, and a write to one that is not has nothing to land in and is refused.

And when a read behind the block does not answer, it says so. If the lookup of your connected systems fails, both lines report that the posture could not be established rather than stating that nothing is connected and nothing is writable. Those are claims about your instance, and they must not be made on the strength of a failed read.

  • Connect Claude Code — step-by-step setup for a coding agent on your own machine, including the authoring endpoint
  • Data model — the canonical tables these tools read
  • Custom tables — discoverable and addressable over MCP
  • Access tokens — the mcp:data permission, which is what lets a token you mint read rows through this endpoint