@closot/mcp
v0.18.0
Published
Closot MCP server — connect Claude, Cursor, or any MCP client to your Closot workspace (pages, blocks, datasources, comments, search). One token, one npx command.
Readme
@closot/mcp
Your Closot workspace, wired into any MCP agent.
@closot/mcp gives Claude Desktop, Claude Code, Cursor, and every other MCP client the same surface you have in the Closot editor: 34 tools across pages, databases, comments, files, people, and search. It was built by running agents against it for days and fixing what actually broke — the design notes below are scars, not aspirations.
Connect in sixty seconds
Grab a token in Closot under Settings → Integrations → New integration (closot_…, shown once — it's workspace-scoped, so it can only ever see the workspace it was created in).
Claude Code:
claude mcp add closot -e CLOSOT_TOKEN=closot_xxxx_yyyy -- npx -y @closot/mcpClaude Desktop — ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):
{
"mcpServers": {
"closot": {
"command": "npx",
"args": ["-y", "@closot/mcp"],
"env": { "CLOSOT_TOKEN": "closot_xxxx_yyyy" }
}
}
}Cursor — .cursor/mcp.json (project) or ~/.cursor/mcp.json (global):
{
"mcpServers": {
"closot": {
"command": "npx",
"args": ["-y", "@closot/mcp"],
"env": { "CLOSOT_TOKEN": "closot_xxxx_yyyy" }
}
}
}VS Code (Copilot) — .vscode/mcp.json:
{
"servers": {
"closot": {
"command": "npx",
"args": ["-y", "@closot/mcp"],
"env": { "CLOSOT_TOKEN": "closot_xxxx_yyyy" }
}
}
}Zed — settings.json:
{
"context_servers": {
"closot": {
"command": {
"path": "npx",
"args": ["-y", "@closot/mcp"],
"env": { "CLOSOT_TOKEN": "closot_xxxx_yyyy" }
},
"settings": {}
}
}
}Connecting a second workspace is a second server entry with that workspace's token — tokens never cross workspaces, and namespacing keeps the model certain about where it's acting.
The markdown loop
Page content flows in and out as Markdown — the format models are best at — with the full verb set: read, create, append, edit. Here's a realistic agent sequence:
① closot_get_page_markdown { pageId }
→ "# Q3 Roadmap\n\n## Goals\n- Ship the mobile beta in Q3…"
② closot_update_page_markdown { pageId, edits: [{ find: "Q3", replaceWith: "Q4" }] }
→ { edits: [{ find: "Q3", occurrences: 7, blocksTouched: 5 }], notFoundFinds: [] }
③ closot_create_page_from_markdown { pageId, markdown: "## Risks\n- …" }
→ appends a Risks section at the bottom; the title stays untouchedThe parser behind create/append/edit is the same canonical importer Closot's own Markdown import uses, so agent-written content and human-imported content render identically. Block-level JSON (closot_get_page_blocks, closot_batch_create_blocks) remains available for when you need attrs, marks, or ids — but most agents never do.
Editing has two deliberately different gears:
edits: [{ find, replaceWith }]— surgical text fixes. Only text leaves are touched, so bold, links, and mentions survive an edit running through them. Case-sensitive, plain text, and honest about misses: anything unmatched comes back innotFoundFindsinstead of failing the whole call.replaceContentMarkdown— rewrite the page. Child pages and embedded databases survive (they're navigation targets other people link to, not prose), replaced blocks land in Trash rather than oblivion, and the title is never renamed by a content edit.
Design notes for agent builders
Things this server does that you'd otherwise discover the hard way:
Every tool declares its blast radius. _meta.riskTier is auto (read-only), preview (mutates, reversible), or confirm (irreversible — permanent delete, column drop). Agent frameworks can gate confirm behind a human without maintaining their own denylist.
Updates merge; replacing is opt-in. closot_batch_update_blocks preserves the fields you don't send — renaming a database row cannot silently wipe its cells. Pass replace: true when you genuinely want overwrite semantics.
Read-only means absent, not refusing. With --read-only (or CLOSOT_READ_ONLY=true) write tools are never registered — they don't appear in tools/list, so a model can't be prompted into calling one. It's a courtesy gate on top of server-side ACL, not a substitute: prefer a dedicated token you can revoke.
Comment threads carry a termination signal. closot_get_comments reports awaitingReply — whether someone other than you spoke last. Closot threads have no stored "resolved" flag, and inventing one would loop agents forever; who-spoke-last actually flips when you answer, and flips back when they follow up. Replies go in-thread via replyToCommentId, and @[Full Name](userId) mentions notify (a bare @Name is inert text).
Lenient clients are repaired, not rejected. Some MCP clients JSON-stringify nested arguments (blocks: "[…]"). The registration layer coerces those back — but only for fields whose schema expects structure, so a comment whose literal body is {"a":1} still round-trips verbatim.
Errors name what's missing. Tools that need a newer closot-api respond with a structured error naming the absent endpoint, so an agent talking to an older deployment learns why instead of guessing.
The server holds no permissions of its own. Every call is authenticated with your token and authorized by closot-api against the same per-page ACL the web editor enforces. Revoke the integration in Settings → Integrations and the token dies immediately. Never commit tokens; pass them as env vars.
Tools
auto = read-only · preview = mutates, reversible · confirm = irreversible.
| | Tool | Tier |
|---|---|---|
| Identity & search | closot_whoami — acting user, workspace, root parent | auto |
| | closot_search — full-text across the workspace | auto |
| | closot_list_pages · closot_list_workspace_members | auto |
| Read pages | closot_get_page_markdown · closot_get_page_meta · closot_get_page_blocks · closot_get_blocks_by_ids | auto |
| Write pages | closot_create_page_from_markdown — create, or append via pageId | preview |
| | closot_update_page_markdown — replace content, or find/replace edits | preview |
| | closot_batch_create_blocks — granular types: heading_2, callout, todo, … | preview |
| | closot_batch_update_blocks — merge-by-default updates | preview |
| | closot_reorder_blocks · closot_trash_blocks · closot_restore_blocks | preview |
| | closot_publish_page · closot_set_page_type · closot_set_page_icon · closot_update_page_cover | preview |
| | closot_permanent_delete_blocks — skips trash | confirm |
| Databases | closot_get_datasource · closot_query_database — schema, filtered rows | auto |
| | closot_create_database — database + view + schema in one call | preview |
| | closot_add_database_rows — rows with values, incl. person cells | preview |
| | closot_create_datasource · closot_create_datasource_property — incl. formula + relation | preview |
| | closot_update_datasource_property_value · closot_reorder_datasource_properties | preview |
| | closot_delete_datasource_property — drops the column and its data | confirm |
| Collaboration | closot_get_comments — threads with awaitingReply | auto |
| | closot_add_comment — new thread, or in-thread reply; mentions notify | preview |
| | closot_create_workarea · closot_add_workarea_people | preview |
| | closot_upload_file — base64 → durable public URL | preview |
Self-hosting
The default transport is stdio: one process per client, started by the client itself — everything above just works. For a shared deployment, the same binary serves Streamable HTTP:
npx -y @closot/mcp --transport http --port 3000POST /mcp speaks JSON-RPC, GET /mcp streams server notifications, DELETE /mcp ends a session, and GET /health is an unauthenticated liveness probe. Requests are bounded at 4 MB.
The endpoint is protected by a gateway secret that is distinct from any Closot token: pass --auth-token/AUTH_TOKEN, or let the server mint one at startup — it's written to a mode-0600 file whose path is printed, and never logged. Bearer comparison is constant-time. Turning auth off requires the self-describing --unsafe-disable-auth, which switches on Host/Origin DNS-rebinding checks as partial compensation — isolated networks only.
One deployment can serve one workspace (set CLOSOT_TOKEN at startup) or many (start with --enable-token-passthrough; each client presents its own token in a Closot-Token header on initialize). Tokens bind to their session, sessions never share an identity, and logs only ever see redacted prefixes. Put TLS in front, keep the gateway secret on.
curl -H "Authorization: Bearer <gateway-secret>" \
-H "Closot-Token: closot_xxxx_yyyy" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' \
http://127.0.0.1:3000/mcpDocker: docker compose build produces closot-mcp:local — multi-stage, unprivileged node user, CLI rebuilt from source so the image can never ship stale. docker compose --profile http up runs the HTTP transport published on loopback; for client configs, docker run --rm -i -e CLOSOT_TOKEN closot-mcp:local slots into the command/args of any JSON above.
Reference
Flags
| Flag | Default | |
|---|---|---|
| --transport <stdio\|http> | stdio | Which transport to serve |
| --port / --host | 3000 / 127.0.0.1 | HTTP bind |
| --auth-token <secret> | minted at startup | Gateway secret for the HTTP endpoint |
| --unsafe-disable-auth | off | No gateway auth; DNS-rebinding checks on. Isolated networks only |
| --enable-token-passthrough | off | Per-client Closot tokens via Closot-Token |
| --read-only | off | Register read tools only |
| --version / --help | | |
Environment
| Variable | | |
|---|---|---|
| CLOSOT_TOKEN | required | Integration token (closot_…) |
| CLOSOT_API_BASE_URL | https://api.closot.com | Point at a dev API with e.g. http://localhost:3002 |
| CLOSOT_AGENT_ID | — | Audit attribution for agent frameworks |
| AUTH_TOKEN / ENABLE_TOKEN_PASSTHROUGH / CLOSOT_READ_ONLY | — | Env forms of the flags above |
With CLOSOT_TOKEN unset, the server falls back to explicit configuration (used by closot-agents' in-process hosting). All four required: CLOSOT_API_BASE_URL, CLOSOT_INTERNAL_API_KEY, CLOSOT_ACTOR_USER_ID, CLOSOT_WORKSPACE_ID.
When something's off
- "CLOSOT_TOKEN does not look like a Closot integration token" — tokens start with
closot_; you pasted something else (often the integration's name). - 401/403 mid-conversation — token revoked, or the page lives in a different workspace than the token. Tokens never cross workspaces.
- A tool errors naming a missing endpoint — your closot-api predates that tool. Upgrade the API, or pin the package:
npx -y @closot/mcp@<version>. - Write tools missing from the list — you're in
--read-onlymode (the startup log says so), or the client cached an old tool list; restart it. - Which version is running? —
npx -y @closot/mcp --version.
Development
npm install && npm run build && npm testnpm run build compiles dist/ (tsc) and bundles the single-file CLI bin/cli.mjs (esbuild) — the bundle is why npx cold-starts fast enough for desktop clients. Point a client at a local build with "command": "node", "args": ["/path/to/closot-mcp/bin/cli.mjs"] and CLOSOT_API_BASE_URL at your dev API.
History in CHANGELOG.md · release process in RELEASING.md · architecture: the server depends on the structural ClosotServicePort interface, never a concrete client, so the HTTP bridge client, an in-process service, or a test double can all drive it. Third-party providers (Slack, Gmail, Google Calendar, GitHub, Jira, Linear) live in @closot/third-party-mcp.
