MCP server
Basalt ships a first-party MCP server, so an agent — Claude, an agent CLI, anything that speaks the protocol — can read and write a workspace without anyone writing glue code.
There is nothing to install. The server runs on your Basalt instance and
answers at https://app.basaltapp.io/mcp; you point an agent at that address
and give it a token.
It is a thin layer over the public API: the endpoint holds no permission logic of its own but makes ordinary API requests with your credential, so an agent sees and changes exactly what you can. Pages cross the wire as canonical Markdown.
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.
A token minted here works here — it reads and writes what Basalt shows you, and nothing the other apps hold. That also means you do not have to tell the agent which workspace to use: the token names one, and the endpoint reads it from there.
In an MCP host's configuration file:
{
"mcpServers": {
"basalt": {
"type": "http",
"url": "https://app.basaltapp.io/mcp",
"headers": {
"Authorization": "Bearer basalt_pat_…"
}
}
}
}
Hosts differ in how they spell this; what they all need is the address and the
Authorization header. In Claude Code, for example:
claude mcp add --transport http basalt https://app.basaltapp.io/mcp \
--header "Authorization: Bearer basalt_pat_…"
If your account is in several workspaces and you want to name one explicitly,
address https://app.basaltapp.io/mcp/w/<workspace id> instead. A token may
only ever name its own workspace, so the two have to agree.
Keeping an agent read-only is the token's job, not a setting: a
content:read token is refused by the server on every write, which is a
stronger guarantee than a switch on the client side.
Adding it as a connector
Hosts that offer no header field — claude.ai among them — add the endpoint as
a connector and sign you in instead. Paste the same address,
https://app.basaltapp.io/mcp, into the connector dialog; the rest is a sign-in and one
screen asking whether you want to connect. Nothing to copy, no token to keep.
A connector is broader than a token, and the screen says so before you agree:
- It covers every workspace you are a member of, not one.
- It reads and writes what you can. There is no read-only connector; mint
a
content:readtoken for an agent that should only look. - It still sees Basalt only, and it stops working when your access does.
Because a connector covers several workspaces, the agent has to say which one
it means: https://app.basaltapp.io/mcp/w/<workspace id>.
When something is refused
The endpoint answers the way the API does, so a refusal says which of three things happened:
- 401 — no credential, or one that is unknown, revoked or expired.
- 403 — a credential that may not do this: a token without
content:readat all, acontent:readtoken asked to write, or one belonging to a different workspace than the address names. - Everything else is the ordinary API's answer, passed through with the server's own wording.
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. |
Five fields you can rely on
Every Basalt workspace has these, under exactly these keys, and nobody can
rename, retype or delete them. Read them in a page's frontmatter, write them
with update_page_fields, filter on them with query_database.
| Key | Type | What it is for |
|---|---|---|
summary |
text | One or two sentences saying what the page is about. search_pages and list_pages both print it under each hit, so you can pick what to open without opening anything. |
status |
select | Draft, In Review, Approved, Published, Outdated, Archived — tells you whether to trust a page. The list is the same in every workspace. |
page_type |
select | Core, Process, Guide, Reference, Decision, Meeting notes, Template, Archive. Also the same everywhere. |
tags |
multi-select | Free vocabulary. A value that does not exist yet is created when you write it. |
related |
relation | Curated links between pages, symmetric: relating A to B relates B to A. |
Writing summary on a page you have just created or rewritten is the single
most useful thing an agent can do for the next one.
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_pagereplaces 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_pagereturns one; passing it toupdate_pagemakes 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_pagesorquery_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, 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.