Docs/MCP/MCP tool surface

MCP tool surface

The desktop app runs a local MCP server — a loopback-only (127.0.0.1) JSON-RPC endpoint (protocol 2025-06-18) an agent or the solomon CLI's loopback transport talks to. It exposes initialize, tools/list, tools/call, notifications/initialized, and ping. Auth is a per-install bearer token (or one of several minted keys) compared constant-time; tool failures ride the MCP isError content shape, never a JSON-RPC error.

The server defines 17 tools: 8 read tools and 9 write tools.

Read tools (8)

Tool What it does
search Hybrid lexical + semantic search (falls back to lexical-only without a local embedding index). regex: true forces a case-insensitive regex over title+body. query accepts inline metadata-filter syntax (path:, tag:, has:/-has:, [key:op value]) that ANDs with the structured path/where params (see Docs/Search/Metadata filters.md). Returns ranked {note_id, rel_path, title, score, snippet, source}; fields: "metadata" omits snippet.
notes_list List every note ({note_id, rel_path, title, frontmatter, modified_ms}, newest first). limit (default 200, max 1000), since_ms filter. Notes without a frontmatter id get a synthetic path:<rel_path> note_id.
base_query Run a base DSL query (dsl string) — from notes, where <field> <op> <value>, sort, columns, group by, view, limit. Fields are frontmatter keys plus meta fields __title/__path/__modified/__folder. Ops: is/is not/contains/exists/missing/gt/lt/under (folder hierarchy). Parse/eval failures come back in result.error, never a tool error.
note_read Read one note (rel_path) → {rel_path, frontmatter, body}, plus agent_md when an AGENT.md/AGENTS.md governs the note's directory (nearest wins). Refuses _private/, dot-dirs, Widgets/.
vaults List vaults open in the app right now (one per window, M19 multi-window): {handle, name, path, active}. Pass a row's handle or path as the vault arg to any other tool to target it.
widget_list List Widgets/*.html as metadata: {id, rel_path, name, dsl, description, data, error}. Never returns the widget HTML itself (it carries JS).
skills_list List Skills/<name>/SKILL.md as metadata: {id, rel_path, name, description, error}.
skill_read Read one skill in full by id{id, name, description, rel_path, body}.

Write tools (9)

Gated behind a persisted writes_enabled toggle (Settings → Local MCP server → Allow writes) — the read tools ignore this gate, so a user can run a read-only agent without disabling the server. Also all gated by the access frontmatter note-level lock (see below).

Tool What it does
note_create Create a note at rel_path with full content (may include --- frontmatter). Fails if the file already exists (never clobbers). Refuses _private/, dot-dirs, Widgets/. Used for Themes/<name>.css and Plugins/<id>/… too — both are plain vault files.
note_write Overwrite a note's body and/or patch frontmatter_patch (object; null value deletes that key), CRDT-safe. At least one of body/frontmatter_patch required. Markdown only.
note_append Append text to a note's body, CRDT-safe. ensure_newline (default true) inserts a leading newline if the body doesn't already end with one.
note_edit_section Tactical in-place edit of PART of a body: target a unique anchor substring (must occur exactly once) OR a heading (its section runs to the next heading of same/higher level). op: replace / insert_before / insert_after / append_within (heading only). Empty content + replace deletes the target/section.
note_set_properties Set/delete frontmatter properties (incl. tags) — object of key→value; null deletes a key.
note_move Move/rename a note or folder; preserves frontmatter id + history. Refuses converting between markdown and HTML, overwriting an existing distinct entry, or touching _private//dot-dirs/Widgets/.
note_delete Send a note/folder to the OS trash (recoverable, never a hard unlink). Requires confirm: true.
widget_write Author a widget (Widgets/<id>.html, a sandboxed srcdoc HTML doc) — the sanctioned path since note_* tools refuse Widgets/ and a widget is a raw file, not a CRDT note. html must contain a <script type="application/json" id="solomon-widget">{…}</script> manifest (all fields optional). A propose key can only stage a new widget; replacing an existing one needs a force key. expected_hash (sha256 from a prior write/read) guards a force-mode write against a concurrent change — mismatch refuses with a conflict: error. A force-mode replace always archives the previous file to <id>.html.bak first.
skill_to_agent Deprecated — one-shot, non-syncing clone of a skill into a new Agent note. Prefer setting the agent's skills frontmatter key instead (composed into the system prompt at every dispatch, stays in sync, reversible). Will be removed.

Every tool except vaults accepts an optional vault selector (folder-name handle or path) — M19 multi-window support.

The propose-vs-force key model

Each request's bearer token resolves to a minted key entry ({id, token, label, mode, scope, created_at} in mcp.json), matched constant-time across the whole key set. Two independent axes:

  • mode: propose | force. A propose-mode key (the default — a fresh install migrates its legacy single token into one) makes every write tool stage a pending change for human review in the app instead of applying, with the key's label recorded as the proposal's source (provenance badge). A force-mode key applies directly. Unknown/garbage mode values in a hand-edited mcp.json fall back to the safe default (propose) — a config file can never silently grant force.
  • scope: local | cloud | both. Where the key may be used. This loopback server is inherently local, so it accepts local/both and refuses a cloud-only key (-32004); the cloud MCP route (app.lomon.dev/api/mcp) enforces the mirror. Unknown/absent scope defaults to local (a key can't silently widen to cloud reach).

Key management (mint / list / revoke / set mode / set scope) lives in Settings → Local MCP server and is not exposed as an MCP tool. Revoke and set-scope both refuse to leave the key set with zero usable-locally keys, so a user can never lock themselves out of a still-listening server.

Note-level access control (access frontmatter)

Independent of the propose/force axis, every write tool is gated on the target note's access frontmatter value (read-only or private; absent/anything else = default/no restriction), tightened further by a per-caller agent_overrides map keyed on the resolving key's label ("read-only" / "no-access" levels). access and agent_overrides are themselves protected — no MCP tool may create, modify, or delete either key (in both propose and force mode), closing a self-escalation path. A read-only note refuses writes with an explicit message; a private note refuses with the same "note not found or unreadable" shape a genuinely missing note would give (so an agent can't distinguish "doesn't exist" from "exists but is private" — including via note_create/note_move's collision-detection path, which is masked the same way).

Quarantine

_private/, any dot-directory (.solomon/, etc.), and Widgets/ are refused by every note-level tool — this is an inversion of the in-app human surfaces (which deliberately include _private/): MCP is exactly the agent surface those directories are quarantined from.

Force-mode side effect: cloud-push notification

A successful force-mode write lands in the app's document registry/on disk only — the frontend's per-open-doc cloud sync transport doesn't automatically see it. The server emits an internal mcp:note-written event after any force-mode note write so the frontend can push that doc to the sync server. Propose-mode writes don't need this — the eventual accept is applied by the frontend, which already owns a live sync session.

Related

  • [[solomon CLI reference]] — the CLI's loopback transport calls this exact server.
  • [[Agents and skills]] — the propose/force model these write tools implement for first-party agents too.
  • [[Metadata filters]] — the inline filter grammar the search tool's query/path/where params accept.