Basalt docs
GitHub

MCP server

Basalt ships a first-party MCP server, so an agent — Claude Desktop, an agent CLI, anything that speaks the protocol — can read and write a workspace without anyone writing glue code.

It is a thin layer over the public API. It holds no database connection, no session secret and no permission logic of its own: it is a REST client (ADR-0009) that presents a personal access token, and pages cross the wire as canonical Markdown (ADR-0012). Everything the model may see or change is decided by the same server code that answers the web app.

Setting it up

Mint a token first: Settings → Tokens, in the workspace you want the agent to work in. Give it content:read if the agent should only read, content:write if it should also write. The secret is shown once.

The server is configured entirely through environment variables:

Variable Required Meaning
BASALT_URL yes Origin of your Basalt instance, e.g. https://notes.example.com.
BASALT_TOKEN yes The personal access token.
BASALT_WORKSPACE_ID yes The workspace UUID — it is in the workspace URL in the app.
BASALT_READ_ONLY no true to not even offer the write tools.
BASALT_TIMEOUT_MS no Per-request timeout, default 30000.

In an MCP host's configuration file that looks like this:

{
  "mcpServers": {
    "basalt": {
      "command": "node",
      "args": ["/path/to/basalt/apps/mcp/dist/basalt-mcp.js"],
      "env": {
        "BASALT_URL": "https://notes.example.com",
        "BASALT_TOKEN": "basalt_pat_…",
        "BASALT_WORKSPACE_ID": "0b1e…"
      }
    }
  }
}

Build it once with pnpm --filter @basalt/mcp build.

The workspace id is a separate variable rather than something the server works out for itself, and that is a real rough edge: a token names exactly one workspace server-side, but no route a token may call reports which one. The fix is a REST route, not a private back channel — until then, the id is pasted next to the credential.

Checking it before an agent does

On start the server makes one request and says what it found:

basalt-mcp: connected to "Acme" at https://notes.example.com

A wrong token or a wrong workspace id stops the process with the reason. An instance that is merely unreachable does not: it starts anyway, because refusing to start would turn a restart-ordered outage into a dead MCP server that the host will not bring back on its own.

If the host reports a broken server with no output, check that nothing writes to stdout — on a stdio transport stdout is the protocol.

What the agent gets

Twelve tools. Read-only unless marked.

Tool What it does
get_workspace The workspace, your role, and every collection you can see with your access level there. The first call in an unfamiliar workspace.
search_pages Full-text search over titles and bodies. How you find a page id.
list_pages Structural listing: the children of a page, or the top level of a collection.
get_page A page as canonical Markdown with field values as YAML frontmatter, plus a version string.
list_databases The databases in the workspace or in one collection.
get_database A database's columns: keys, types, whether required, and the allowed option labels.
query_database Rows with filters and sorts. Every row carries a pageId.
create_page write — a page in a collection, optionally under a parent, optionally with a body.
update_page write — replaces the whole body and/or renames. Takes the version from get_page.
update_page_fields write — sets field values without touching the body.
create_database_row write — a row, which in Basalt is a page.
trash_page write — moves a page and its subtree to the trash. Reversible; there is deliberately no permanent-delete tool.

Two resources are offered for attaching context directly: basalt://workspace (collections and databases with their ids) and basalt://page/{pageId} (a page as Markdown).

Three behaviours are worth knowing because they change how an agent should act:

  • update_page replaces the body. Read the page, change the text you got, send all of it back. A fragment truncates the page.
  • Versions are optimistic locks. get_page returns one; passing it to update_page makes the write fail rather than overwrite an edit somebody made in between. That refusal is the feature.
  • Ids are never guessed. They come from get_workspace, search_pages, list_pages or query_database.

What it can and cannot do

The token is the identity. The server acts as the person who created the token, with exactly their access — it cannot do more, because there is no code path below the API that would let it. A page the person cannot read is not returned, does not appear in listings, and is not found by search.

Consequently the agent sees a viewer-dependent workspace: row counts, search results and page trees are what that person can see, not what exists.

Some refusals are the operator's to fix and some are not, so the server distinguishes them in the message it hands the model:

  • A missing scope names the exact scope string and says retrying will not help — someone has to mint a different token.
  • Content the user may not read says so, and points at search_pages.
  • Not found deliberately does not claim to know whether the page is absent or merely unreadable — Basalt cannot tell those apart on purpose (ADR-0016), and pretending otherwise would send an agent hunting for a typo that does not exist.

BASALT_READ_ONLY=true withholds the write tools entirely. It cannot widen anything — a content:read token already refuses every write — but "the model never saw a write tool" is a stronger operational statement than "the write always failed", and it saves the agent the turns it would spend finding out.

Token management is not reachable from here at all. A credential that can mint credentials escalates quietly and outlives the revocation of the first, so the token surface is session-only, by design.