@centrali-io/centrali-mcp
MCP (Model Context Protocol) server for the Centrali platform. Lets AI assistants (Claude, Cursor, etc.) interact with Centrali workspaces — query data, manage records, search, trigger compute functions, inspect execution results, build orchestrations, and more.
Full documentation: docs.centrali.io — SDK guide, API reference, compute functions, orchestrations, and more.
File uploads
Both hosted and stdio servers provide upload_file. It uploads a standalone Storage
file through the existing SDK and Storage API, using the authenticated workspace
and its existing folder permissions. Node.js 22 or newer is required for stdio.
{
"filename": "hello.txt",
"contentType": "text/plain",
"contentEncoding": "utf8",
"content": "Hello from MCP"
}
For binary files use contentEncoding: "base64" with canonical padded base64
(no data URL prefix or whitespace). The decoded limit is 256 KiB, including
UTF-8 byte length for text. Empty files are supported. The serialized content
JSON string also has a 768 KiB limit so JSON escaping leaves room within the
hosted server's 1 MB request limit. For heavily escaped text (such as control
characters), encode the exact UTF-8 bytes as base64 instead.
location defaults to /root/shared; custom folders
must already exist. Files are private unless isPublic: true is explicitly set.
Public uploads allow only PNG, JPEG, GIF, WebP, plain text, JSON, MPEG/OGG/WAV
audio, and MP4/WebM video MIME types. HTML, SVG, XML and other active document
types cannot be made public through this tool because Storage serves the
supplied MIME type inline on the API origin. Private uploads retain support
for arbitrary MIME types. The SDK prefixes stored filenames with the workspace
and a timestamp.
The result contains renderId, sizeBytes, contentType, isPublic, renderUrl
and downloadUrl. Private URLs still require normal authentication. Attach the
returned render ID using a separate record update; an attachment failure leaves
the uploaded file in Storage. Repeating an upload can create another file.
The tool accepts content supplied by the client. It cannot read local paths, fetch source URLs, or access chat attachments/generated images hidden by the host. Never reconstruct image bytes through model transcription. Files larger than 256 KiB must use the existing SDK/HTTP multipart upload API.
Connection options
Option 1: Hosted MCP Server (recommended)
The easiest way to connect any AI client to Centrali. Add a single URL — authentication happens in the browser via OAuth.
Claude Desktop / Claude Code / Cursor:
{
"mcpServers": {
"centrali": {
"type": "url",
"url": "https://mcp.centrali.io"
}
}
}
On first use, your AI client opens a browser for login and workspace selection. No client ID, secret, or workspace slug needed.
Option 2: Stdio with Service Account (CI/automation)
For headless environments (CI pipelines, automation scripts, cron jobs) where browser login isn't possible. Uses service account credentials with RBAC permissions.
{
"mcpServers": {
"centrali": {
"command": "npx",
"args": ["@centrali-io/centrali-mcp"],
"env": {
"CENTRALI_URL": "https://centrali.io",
"CENTRALI_CLIENT_ID": "<service-account-client-id>",
"CENTRALI_CLIENT_SECRET": "<service-account-secret>",
"CENTRALI_WORKSPACE": "my-workspace"
}
}
}
}
Migrating from OAuth stdio to hosted
If you were previously using CENTRALI_OAUTH_CLIENT_ID with the stdio package, switch to the hosted URL:
- Remove the old
command/args/envconfiguration - Replace with:
{ "type": "url", "url": "https://mcp.centrali.io" } - On next connection, authenticate in the browser
Environment Variables (stdio mode only)
| Variable | Required | Description |
|---|---|---|
CENTRALI_URL | Yes | Centrali instance URL (e.g., https://centrali.io) |
CENTRALI_WORKSPACE | Yes | Workspace slug to operate in |
CENTRALI_CLIENT_ID | Yes | Service account client ID |
CENTRALI_CLIENT_SECRET | Yes | Service account client secret |
Service Account Permissions
A freshly created service account has no permissions by default. You must assign it a role before the MCP server can do anything useful, otherwise every tool call will return 403 Forbidden.
Quickest setup — full admin access:
- In the Console, go to Settings > Service Accounts
- Create your service account and save the client secret
- Open the service account, go to the Groups tab
- Add it to the workspace_administrators group
Least-privilege setup: Create a custom group with a policy scoped to the resources you need. See the Policies and Permissions guide for details.
Getting Started
After connecting, call describe_centrali first — it returns the full capability map, feature matrix, and SDK integration guidance. Then use the specific describe_* tools for deeper schema details on any domain.
Available Tools
Discovery (call these first)
| Tool | Description |
|---|---|
describe_centrali | Platform overview, feature matrix, SDK integration guidance, and list of all tools |
describe_collections | Schema reference for collections (data models, field types, constraints) |
describe_records | Schema reference for record operations (filters, sorting, pagination, expand) |
describe_compute | Compute function reference (input contract, secrets, async execution, api object) |
describe_saved_queries | Saved-query schema reference (parameterized queries, variables) |
describe_orchestrations | Orchestration reference (multi-step workflows, encrypted params) |
describe_insights | Anomaly insights reference |
describe_validation | Data validation reference |
describe_auth_providers | Auth provider reference (BYOT setup, claim mappings) |
describe_service_accounts | Service account & IAM reference (SA setup, roles, groups, permissions) |
describe_webhooks | Outbound webhook reference (subscription shape, signature, retry/replay) |
Collections
| Tool | Description |
|---|---|
list_collections | List all data collections (schemas) in the workspace |
get_collection | Get full schema for a collection by record slug |
create_collection | Create a new collection with typed properties |
update_collection | Update an existing collection |
delete_collection | Delete a collection and all its records |
Records
| Tool | Description |
|---|---|
query_records | Query records using the canonical Centrali query language (POST /records/query). Accepts a QueryDefinition body (where, text, sort, page, select). |
get_record | Get a single record by ID |
get_records_by_ids | Get multiple records by IDs |
create_record | Create a new record |
update_record | Update an existing record |
upsert_record | Create or update a record |
delete_record | Soft or hard delete a record |
restore_record | Restore a soft-deleted record |
Compute
| Tool | Description |
|---|---|
list_functions | List compute functions |
get_function | Get a function by ID (includes code) |
create_function | Create a new compute function |
update_function | Update a function (code, name, timeout) |
delete_function | Delete a compute function |
test_function | Test execute code without saving |
list_triggers | List function triggers (on-demand, event-driven, scheduled, http-trigger) |
get_trigger | Get a trigger by ID |
create_trigger | Create a new trigger |
update_trigger | Update a trigger |
delete_trigger | Delete a trigger |
invoke_trigger | Invoke an on-demand trigger (returns job ID — async) |
pause_trigger | Pause a trigger |
resume_trigger | Resume a paused trigger |
test_send_trigger | Send a signed synthetic test event through an http-trigger's real verify/dispatch path |
get_function_run | Get execution result by run ID (status, output, errors) |
list_function_runs | List runs by trigger or function ID (execution history) |
get_compute_job_status | Check async job status by job ID (poll after invoke_trigger) |
list_allowed_domains | List allowed domains for outbound HTTP |
add_allowed_domain | Add a domain to the allowlist |
invoke_endpoint | Call a sync compute endpoint by path (returns response inline) |
remove_allowed_domain | Remove a domain from the allowlist |
Saved Queries
Saved queries store a canonical QueryDefinition plus optional variables and an optional multi-collection joins[] clause.
| Tool | Description |
|---|---|
list_saved_queries | List saved queries, optionally by collection |
get_saved_query | Get a saved query by ID |
create_saved_query | Create a reusable parameterized query (canonical body) |
update_saved_query | Update a saved query (canonical body) |
delete_saved_query | Delete a saved query |
execute_saved_query | Execute a saved query with optional variables |
test_saved_query | Test a canonical query definition without saving |
Typed parameter discovery → execute. Saved queries authored with typed parameters expose their parameter shape through describe_saved_queries (schema reference) and get_saved_query / list_saved_queries (per-row variables declarations). The expected discover-then-execute flow:
- Discover schema (one-shot) —
describe_saved_queriesreturns theQueryVariableDefinitionshape (type,required?,default?,description?) and the supportedVariableTypeunion (string|number|boolean|datetime|id|{ array: T }|{ reference: collectionSlug }). - Discover the query —
get_saved_queryreturns the row includingvariables. Read each entry to learn what to bind. The canonical response includesqueryDefinition.resource(always equal to the row'srecordSlug):{ "id": "…", "name": "Monthly revenue", "recordSlug": "orders", "queryDefinition": { "resource": "orders", "where": { "and": [ { "data.month": { "eq": "${month}" } }, { "data.region": { "eq": "${region}" } } ]}, "sort": [{ "field": "data.amount", "direction": "desc" }], "page": { "limit": 100 } }, "variables": { "month": { "type": "datetime", "required": true, "description": "Month bucket (UTC)" }, "region": { "type": "string", "required": true } } } - Execute typed — pass concrete values via
execute_saved_query'svariablesarg. The server validates each value against its declared type (no coercion —"123"will not be accepted for anumberdeclaration) and then substitutes.datetimeaccepts an ISO-8601 string:
Typed errors surface as{ "recordSlug": "orders", "queryId": "…", "variables": { "month": "2026-04-01T00:00:00Z", "region": "us-east" } }variable_type_mismatch,missing_required_variable, orextra_variable. Untyped (legacy) rows havevariables: nulland accept any keys; values are stringified before substitution. The untyped fallback is also taken by post-backfill rows (canonical body, no declarations yet) until an author promotes them.
The MCP test_saved_query tool intentionally does not expose variableDeclarations — its dry-run path falls back to legacy string substitution and does not enforce typed declarations. Round-trip via create_saved_query + execute_saved_query to validate typed parameters end-to-end.
Multi-collection joins. Saved queries support an optional joins[] clause inside queryDefinition (max 4 entries). Each entry joins another collection to the primary record set:
{
"resource": "orders",
"where": { "data.status": { "eq": "open" } },
"joins": [
{
"foreignSlug": "customers",
"localField": "data.customerId",
"foreignField": "id",
"joinType": "left",
"select": ["id", "data.name", "data.email"],
"alias": "customer"
}
],
"sort": [{ "field": "createdAt", "direction": "desc" }],
"page": { "limit": 50 }
}
Constraints (validated by @centrali/query):
joinTypemust be'inner'or'left'—'right'and'full'are rejected by the executor withunsupported_clause.textandjoinscannot appear in the same query (unsupported_combination).- Aliases (or implicit
foreignSlug) must be unique withinjoins[](duplicate_join_alias). - Cap of 4 entries (
joins_length_exceeded). localFieldmay use<priorAlias>.<field>to chain a join through an earlier alias.
execute_saved_query returns { rows, meta, fields }. Each entry in rows carries joined data under a top-level _joined key:
{
"rows": [
{
"id": "order-123",
"data": { "status": "open" },
"_joined": {
"customer": { "id": "cust-1", "data": { "name": "Acme" } }
}
}
],
"meta": { "limit": 50, "offset": 0, "total": 1 },
"fields": [{ "name": "id" }, { "name": "data" }, { "name": "_joined" }]
}
LEFT joins surface null for unmatched rows.
Authorization (SECURITY DEFINER, locked 2026-05-04). Saved queries are author-bound — the AUTHOR's permissions define what the query can read; the executor only needs an execute check on the saved-query resource at runtime.
- Author time (
create_saved_query/update_saved_query/test_saved_query): the caller must holdrecords:liston the primary collection AND on every collection referenced injoins[]. The data service returns 403 if any access is missing. - Execute time (
execute_saved_query): onlyexecuteon the saved-query resource is checked; there is no per-collection re-check, and field-level redaction follows the author's permissions, not the executor's. Secret-masking still applies unconditionally.
AI assistants should not propose saved queries (or join chains) against collections the author can't read — execute will succeed for less-privileged callers, but authoring will fail.
Reserved clauses. Saved-query create/update/test surfaces reject text (full-text search) and include (relation expansion) with 422 unsupported_clause. Those clauses are available on the ad-hoc POST /records/query surface only — don't author saved queries that use them.
Alerts
Run a saved query on a cron and notify people when its result meets a condition.
| Tool | Description |
|---|---|
list_alerts | List alerts, optionally filtered to one saved query |
get_alert | Get an alert by ID, including its evaluation history |
create_alert | Create a scheduled alert on a saved query |
update_alert | Update an alert's schedule, condition, recipients, bindings, or enabled state |
delete_alert | Delete an alert and unschedule its job |
Conditions. Two kinds ship today — { kind: 'rowCount', op, value } (op one of eq/neq/gt/gte/lt/lte) and { kind: 'rowsChanged' }, which fires when the row count differs from the previous evaluation and never fires on its first run (it only seeds the baseline). There is no aggregate kind: an alert reasons about how many rows came back, not about a sum or average of their contents. To alert on an aggregate, author the saved query so the rows it returns are the exception set and alert on rowCount.
Scheduling. cronExpression is a 5-field cron expression interpreted in timezone (IANA name, default UTC). Notifications are throttled to 1 per recipient per 15 minutes per alert, so a sub-15-minute cron evaluates more often than it can notify.
Recipients. recipients is an explicit list — [{ type: 'user', id: '<userId>' }] — not a subscription list. No MCP tool enumerates workspace users, so an assistant must ask the user for IDs rather than constructing them.
Binding variables — stricter than execute. If the saved query declares parameters, bind them via variables. The server validates the map on create and on any update that submits it, and requires every declaration the query body references that has no default, regardless of required:
{
"savedQueryId": "…",
"name": "Failed payments spiked",
"cronExpression": "0 * * * *",
"condition": { "kind": "rowCount", "op": "gt", "value": 10 },
"recipients": [{ "type": "user", "id": "…" }],
"variables": { "status": "failed" }
}
execute_saved_query accepts an unbound optional and leaves the raw ${name} in the body; the query then filters on the literal placeholder, matches nothing, and reports no error. Interactively that's visible — the caller sees an empty result. On a cron nobody is watching, so the alert would simply never fire and lastEvaluatedAt would keep advancing as though healthy. Authoring is the last point where the gap is noticeable, so it fails there with a 400 naming each offending variable.
Diagnosing "it never fired". get_alert exposes lastEvaluatedAt, lastTriggeredAt, lastRowCount, and the health fields consecutiveFailures, lastError, lastErrorAt.
Check consecutiveFailures first. Non-zero means the saved query behind the alert is erroring, so the alert is scheduled and enabled but cannot fire; lastError carries the sanitized reason and lastErrorAt when it last happened. The commonest cause is a collection or field renamed out from under the query. The counter is a run length that resets on the first successful evaluation, so a 0 rules the entire failure class out rather than merely suggesting health.
With consecutiveFailures at 0, a null or stale lastEvaluatedAt means the alert is disabled or was never scheduled. A current lastEvaluatedAt with a null lastTriggeredAt means the query runs fine and the condition isn't matching — compare the alert's variables against the saved query's declarations.
Orchestrations
| Tool | Description |
|---|---|
list_orchestrations | List all orchestrations in the workspace |
get_orchestration | Get orchestration details including step definitions |
create_orchestration | Create a multi-step workflow |
update_orchestration | Update an orchestration (steps, triggers, encrypted params) |
delete_orchestration | Delete an orchestration and its runs |
activate_orchestration | Activate an orchestration (enable triggers) |
pause_orchestration | Pause an orchestration (disable triggers) |
trigger_orchestration | Trigger a run with optional input data |
list_orchestration_runs | List runs for an orchestration |
get_orchestration_run | Get run details (use includeSteps=true for step-by-step debugging) |
Insights (Anomaly Detection)
| Tool | Description |
|---|---|
trigger_anomaly_analysis | Start an anomaly detection scan |
list_insights | List anomaly insights |
get_insight | Get a single insight |
acknowledge_insight | Mark an insight as reviewed |
dismiss_insight | Dismiss an insight |
get_insights_summary | Get insights summary counts |
Validation (Data Quality)
| Tool | Description |
|---|---|
trigger_validation_scan | Start a data quality scan |
list_validation_suggestions | List validation suggestions |
accept_validation_suggestion | Accept a fix (applies to record) |
reject_validation_suggestion | Reject a suggestion |
get_validation_summary | Get validation summary counts |
Service Accounts & IAM
| Tool | Description |
|---|---|
list_service_accounts | List all service accounts |
get_service_account | Get service account details |
create_service_account | Create a service account (returns clientId + clientSecret) |
update_service_account_name | Update SA display name |
update_service_account_description | Update SA description |
delete_service_account | Permanently delete a service account |
rotate_service_account_secret | Rotate client secret (old one immediately invalidated) |
revoke_service_account | Permanently revoke a service account |
generate_dev_token | Generate a short-lived dev token for testing |
scan_service_account_permissions | Full access matrix audit (Allow/Deny per resource+action) |
simulate_service_account_permission | Simulate an authorization check with evaluation trace |
generate_remediation | Generate options to grant missing permissions (role, group, or new policy) |
preview_remediation | Preview what changes a remediation option would make |
apply_remediation | Apply a remediation option to grant access |
list_service_account_roles | List roles assigned to a SA |
assign_role_to_service_account | Assign a role to a SA |
remove_role_from_service_account | Remove a role from a SA |
list_service_account_groups | List groups a SA belongs to |
add_service_account_to_group | Add SA to a group |
remove_service_account_from_group | Remove SA from a group |
list_roles | List all roles in the workspace |
get_role | Get role details with permissions |
create_role | Create a role with permissions |
update_role | Update a role |
delete_role | Delete a role |
list_groups | List all groups |
get_group | Get group details |
create_group | Create a group |
update_group | Update a group |
delete_group | Delete a group |
Webhook Subscriptions
| Tool | Description |
|---|---|
list_webhook_subscriptions | List all outbound webhook subscriptions |
get_webhook_subscription | Get a subscription by ID (secret not included) |
create_webhook_subscription | Create a subscription — response returns the whsec_ signing secret once |
update_webhook_subscription | Update name, URL, events, recordSlugs, or active flag |
delete_webhook_subscription | Delete a subscription (delivery history retained) |
rotate_webhook_subscription_secret | Regenerate the signing secret (immediate cutover) |
publish_webhook_event | Publish a named business event (e.g. invoice.paid) and fan it out to matching subscriptions |
list_webhook_deliveries | List delivery history for a subscription (trimmed rows) |
get_webhook_delivery | Get a single delivery with full payload and response body |
retry_webhook_delivery | Replay a delivery — reuses the original payload and signature |
cancel_webhook_delivery | Cancel a retrying delivery (flips to failed with distinctive marker) |
Publishable Keys (Frontend)
| Tool | Description |
|---|---|
list_publishable_keys | List all publishable keys |
get_publishable_key | Get key details with scopes |
create_publishable_key | Create a scoped frontend key (pk_live_...) |
update_publishable_key | Update label or scopes |
revoke_publishable_key | Revoke a key (immediate, irreversible) |
Identity
| Tool | Description |
|---|---|
get_current_identity | Get the current authenticated MCP identity (service account or OAuth user) |
Structures (Deprecated)
| Tool | Description |
|---|---|
list_structures | Deprecated: use list_collections |
get_structure | Deprecated: use get_collection |
Resources
| URI | Description |
|---|---|
centrali://collections | List of all collections with name, slug, description |
centrali://collections/{slug} | Full schema for a specific collection |
Development
# Install dependencies
npm install
# Build
npm run build
# Test with MCP inspector
CENTRALI_URL=https://centrali.io \
CENTRALI_CLIENT_ID=... \
CENTRALI_CLIENT_SECRET=... \
CENTRALI_WORKSPACE=my-workspace \
npx @modelcontextprotocol/inspector node dist/index.js
Service-account identifiers
Account-path tools use the string clientId returned by create_service_account / list_service_accounts, including get, update, rotate, revoke, delete, dev-token, role/group membership, and permission scan/simulate. They no longer accept numeric serviceAccountId inputs. Existing legacy client IDs are supported. Upgrade this package alongside IAM when deploying the client-ID route change.
The separate generate_remediation, preview_remediation, and apply_remediation tools retain their numeric serviceAccountId argument for the access API's targetId contract. Account responses continue to include both id and clientId.
Development tests
npm test builds the sibling SDK first when running in the monorepo, then builds
MCP and runs its tests. The health regression uses the real SDK normalization,
so its runtime distribution must exist even on a fresh checkout. Outside the
monorepo, tests use the installed SDK distribution instead.