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. Apropose-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'slabelrecorded as the proposal'ssource(provenance badge). Aforce-mode key applies directly. Unknown/garbage mode values in a hand-editedmcp.jsonfall 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 acceptslocal/bothand refuses acloud-only key (-32004); the cloud MCP route (app.lomon.dev/api/mcp) enforces the mirror. Unknown/absent scope defaults tolocal(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
searchtool'squery/path/whereparams accept.