MCP Server
The MCP server is how an agent maintains a dashboard: safely, for months, without it rotting. It's the primary way to run OpenIslands. It exposes the same typed, validated path the CLI uses as a set of tools over the Model Context Protocol, built on one principle:
Read many, write one. An agent can read everything (the manifest, the schemas, the live data), but every change funnels through a single proposal-and-apply pipeline that validates before it writes and snapshots before it changes anything.
This is the moat. An agent can't hand-edit your files, can't ship a broken binding, and can't make a change you can't undo.
Wiring it up
The server ships as @openislands/mcp. Point your MCP client's config at your project
directory (the folder that contains app/manifest.json):
// .mcp.json
{
"mcpServers": {
"openislands": {
"command": "npx",
"args": ["-y", "@openislands/mcp", "/path/to/your/project"]
}
}
}The first positional argument is the project root: the server reads and writes the manifest,
data, and history under it. npx fetches and runs the latest published server on demand; its
-y flag skips the install prompt, so there's nothing to install globally.
The read tools
An agent grounds itself before it ever proposes a change. These tools are all read-only:
| Tool | What it returns |
|---|---|
list_islands | The built-in island types and the fields each requires. |
get_island_schema(type) | The exact config schema for one island type. |
get_manifest | The current manifest. |
get_data_schema(dataset) | A dataset's live, DuckDB-inferred columns and types. |
query_data({ dataset } | { sql }, limit) | Rows from a dataset, or a read-only SELECT over the registered dataset views. |
validate_sql({ sql }) | Dry-runs a read-only SELECT against the dataset views; returns its result columns or the exact DuckDB error. |
validate_manifest({ manifest? }) | Validates a manifest (the one on disk, or one you pass) and checks every binding against the data. |
list_checkpoints | The rollback points, newest last. |
query_data takes a dataset name for a whole dataset or a read-only sql SELECT over the
dataset views, never both. This is how an agent confirms a column exists and what its values
look like before binding an island to it.
validate_sql is the same read-only surface, aimed at authoring a transform: paste the SQL you
intend to save as a sql dataset and get back its result columns, or the exact DuckDB error, before
you wire it into the manifest. See CRUD recipes.
The manifest write path
Exactly one pipeline writes the manifest, in two steps with a human-reviewable diff between
them. An agent stages an edit (patch_manifest or propose_edit), reviews the returned diff,
then applies it.
| Tool | What it does |
|---|---|
patch_manifest({ ... }) | The preferred editor. Merges one or more sections into the current manifest, validates the result, and stages it. |
propose_edit({ manifest }) | Stages a full manifest rewrite. |
apply_edit({ proposal_id }) | Writes a staged proposal and snapshots the prior manifest. |
rollback({ checkpoint_id? }) | Restores a snapshot byte-for-byte (the latest if no id). |
patch_manifest — the incremental editor
patch_manifest is how an agent should normally edit the manifest. It takes a partial
manifest — { title?, icon?, datasets?, actions?, queries?, connectors?, pages?, remove_pages? } —
merges it into the document on disk, validates the merged result against the live data, and returns
a unified diff plus a proposal_id (it writes nothing yet). You never re-send, or re-typo,
the whole manifest.
The merge follows the shape of each section:
- Record sections (
datasets,actions,queries,connectors) are keyed maps. Each entry is upserted by name:name → specadds or replaces that one entry, andname → nulldeletes it. Untouched entries are left alone. pagesis a list, so it's upserted byid: a page whoseidalready exists is replaced, a newidis appended.remove_pages: ["id", …]deletes pages by id.- Scalars (
title,icon) are overwritten when present.
// patch_manifest — add one dataset and one page, delete a stale query, all in one call
{
"datasets": { "spending": { "source": "data/spending.csv", "description": "monthly spend" } },
"pages": [
{ "id": "spending", "title": "Spending", "islands": [
{ "type": "category.bar", "title": "By category", "dataset": "spending",
"x": "category", "y": "amount_eur" }
] }
],
"queries": { "legacy_rollup": null }
}The validation is identical to propose_edit's: if a binding fails, you get
{ ok: false, errors, diff } (each error naming the page, island, and field) and no
proposal_id. Fix the patch and call again. On success: { ok: true, proposal_id, diff }.
propose_edit — the full rewrite
propose_edit({ manifest }) takes the whole manifest when you really do want to replace it
end to end. It accepts a manifest object (preferred) or a JSON string — no double-encoding.
It validates the structure, checks every binding against the live data, and returns the same
{ ok, proposal_id?, diff, errors? } shape as patch_manifest.
A proposed manifest is validated against itself: new datasets, sql transforms, and markdown
sources introduced in the same edit resolve and bind correctly, even starting from an empty
manifest. validate_manifest({ manifest? }) runs that same check without staging anything — pass a
manifest object to dry-run it, or omit it to validate the one on disk.
Apply and roll back
apply_edit({ proposal_id }) writes a staged proposal (from either editor). Before writing it
snapshots the current manifest as a checkpoint and returns its checkpoint_id. A proposal is
rejected if it's unknown or stale: if the manifest on disk changed since it was staged (a
content-hash check), re-stage the edit.
rollback({ checkpoint_id? }) restores a checkpoint byte-for-byte (the latest if no id is
given). It restores the manifest and any data checkpoints, so it undoes data writes too.
There is no raw file-write tool and no git dependency by design. Safety is the stage/apply/rollback loop plus on-disk snapshots, not trust.
CRUD recipes
Author against the live contract, stage with patch_manifest, apply.
Add a dataset from a file. Drop the file under data/ (or models/ / docs/ / app/), then
upsert it and confirm its inferred columns:
// patch_manifest
{ "datasets": { "crypto": { "source": "data/crypto.csv", "description": "holdings" } } }Then get_data_schema({ dataset: "crypto" }) before you bind an island to it.
Add a SQL transform — dry-run it first. validate_sql lets you get the SQL right before it ever
touches the manifest:
// 1. validate_sql — returns the result columns, or the exact DuckDB error
{ "sql": "SELECT class, SUM(value_eur) AS value_eur FROM holdings GROUP BY class" }
// 2. once it's valid, save it under models/ and wire it in with patch_manifest
{ "datasets": { "allocation": { "sql": "models/transforms/allocation.sql" } } }A transform can read any other dataset by its name; validate_sql resolves those same views.
Add an island to a page. A page is upserted by id, so read the page, append the island, and
send just that page back (everything you omit on the page is replaced, so include the existing
islands):
// patch_manifest
{ "pages": [ { "id": "overview", "title": "Overview", "islands": [
/* ...existing islands... */,
{ "type": "rank.list", "title": "Top assets", "dataset": "allocation",
"label": "class", "value": "value_eur", "span": 6 }
] } ] }Remove something. Set a record entry to null, or list page ids in remove_pages:
// patch_manifest
{ "queries": { "by_class": null }, "actions": { "log_txn": null }, "remove_pages": ["scratch"] }The data write path: actions
Actions are typed inserts into a source dataset, declared in the manifest. The agent
discovers and runs them:
list_actionsreturns each declared action with its resolved row JSON Schema (derived from the live data, merged with the action'sfieldsoverrides). This is the agent's grounding for what a valid row looks like.run_action(name, rows)validates every row against that schema first. A single bad row rejects the whole call with an error naming the row index and field, and nothing is written. On success the target file is snapshotted (sorollbackcovers it) and the result reports the rowsinsertedplus acheckpoint_id.
Inserts are all-or-nothing and path-confined to the project: an action can only write the
source file its declared dataset names.
The read query path: queries
Queries are typed, read-only reads over a dataset, declared in the manifest. They're the read
mirror of actions: a saved, named, parameterized version of query_data. A query is a declarative
spec — a dataset, filters, a projection — that the compiler translates to a parameterized
SELECT, not raw SQL. The agent discovers and runs them:
list_queriesreturns each declared query with itsname,description, itsparamsas a JSON Schema, and the resultcolumns. This is the agent's grounding for what to pass and what comes back.run_query({ name, params?, limit? })validates the params, then runs the compiledSELECT. On success it returns{ ok: true, rowCount, columns, rows }. A bad param rejects the whole call with{ ok: false, errors }(all-or-nothing); an unknown name or a query error returns{ ok: false, error }.limitis 1–500 and the result is row-capped either way. Params and literals are bound, never interpolated, and everyfieldis validated against the live columns.
A query is plain JSON in the manifest. Because the only thing the write path writes is the
manifest, an agent authors a query through the normal patch_manifest / apply_edit loop, exactly
like an island or an action — it creates the read tool, it doesn't just run one.
See Queries for the full shape.
Connectors
Connectors sync an external provider's data into source datasets on a schedule, through the
same checkpointed write path. The agent's two tools:
list_connectorsreturns each connector's live status:connected,missingSecrets,lastSync,lastError, effectiveschedule, and anyloadError. This is how an agent discovers that auth is missing.run_sync({ name })pulls from the provider and writes rows, returning rows-per-dataset, the write mode (insert/replace), and acheckpoint_id, so a sync is reversible withrollback.
Safety posture
Every guarantee is structural, not advisory:
- Validate before write.
patch_manifest,propose_edit, andrun_actionall fail closed: an invalid manifest or a bad row never reaches disk. - Snapshot before change.
apply_edit,run_action, andrun_synceach snapshot to.openislands/history/first, androllbackrestores any of them byte-for-byte. History is count- and byte-capped, oldest pruned first. - Path confinement. Writes are scoped to the project's declared
sourcefiles; there is no general filesystem access. - Reads are bounded too.
query_dataandrun_queryonly run read-onlySELECTs; a query compiles from a declarative spec with itsfields validated and its params and literals bound, not interpolated, and every result is row-capped. - Prompt-injection posture. Because the only mutations are a validated manifest edit
(
patch_manifestorpropose_edit) and a schema-checked row insert — both diffed or reported, both reversible — data that tries to talk an agent into a harmful edit still can't bypass validation, the diff, or the rollback snapshot.
Related
- Getting Started: the human CLI loop the MCP tools mirror.
- The Manifest: what
propose_editvalidates. - Data Contracts: the binding check behind every write.