Skip to content

MCP Tools Reference

VMark exposes seven composite MCP tools to AI assistants: session, workspace, document, workflow, selection, browser, and coherence. Together they cover the editor spine, file/window lifecycle, CST-safe workflow edits, targeted selection edits, bounded browser navigation, and a read-only view of the workspace coherence layer.

The previous 12-tool / 76-action surface was pruned because in-document formatting tools (bold, headings, tables, etc.) duplicate work that AI agents already do trivially via Markdown round-trip. selection was kept (per ADR-7 of the pruning plan) because the full-doc round-trip is uneconomical on large files — every edit pays the whole document in input tokens, the whole document in output tokens (~5× input price), and a longer write window that widens the stale-revision retry loop. See the MCP pruning plan for the full rationale.

Recommended Workflow

  1. Call session.get_state once to see open windows, tabs, and per-tab {filePath, dirty, revision, kind}.
  2. For small Markdown changes or wholesale rewrites: document.read → reason → document.write (passing expected_revision for safe concurrency).
  3. For targeted edits on a large Markdown file when the user has selected the region to change: selection.get → reason → selection.set (cuts both input and output token cost to the selection).
  4. For GitHub Actions YAML (kind: "yaml-workflow"): workflow.apply_patch for CST-safe edits that preserve comments and anchors; workflow.validate for actionlint diagnostics.
  5. File operations (open, save, close, switch tabs) live on workspace.

Mermaid Diagrams

When using AI to generate Mermaid via MCP, consider installing the mermaid-validator MCP server — it catches syntax errors using the same Mermaid v11 parsers before diagrams reach your document.


session

One-shot orientation. Discover every window, every tab, and the server's capabilities in a single call.

get_state

No arguments.

Returns {windows, capabilities}:

json
{
  "windows": [
    {
      "label": "main",
      "focused": true,
      "tabs": [
        {
          "id": "tab-1",
          "filePath": "/path/to/notes.md",
          "title": "notes",
          "dirty": false,
          "revision": "rev-x7Q3aB1F",
          "kind": "markdown"
        },
        {
          "id": "tab-2",
          "filePath": "/repo/.github/workflows/ci.yml",
          "title": "ci",
          "dirty": true,
          "revision": "rev-x7Q3aB1F",
          "kind": "yaml-workflow"
        }
      ]
    }
  ],
  "capabilities": {
    "version": "<vmark-mcp-server version>",
    "supportedKinds": ["markdown", "yaml-workflow"],
    "mcpProtocol": "0.2.0"
  }
}

The kind discriminator tells you whether to use document.write (for markdown) or workflow.apply_patch (for yaml-workflow) on that tab.


workspace

File and window lifecycle. Nothing in-document.

Path scope. File operations (open, save, save_as) are confined to the open workspace root and the directories of already-open documents. A request for a path outside that scope is refused with INVALID_PATH. With no workspace and no open document, there is no scope, so file operations are refused. This keeps an automated client acting within what you have opened.

new

Create a new untitled tab.

ParameterTypeRequiredDescription
kindstringNo"markdown" (default) or "yaml-workflow"
windowLabelstringNoTarget window; defaults to focused

Returns {tabId}.

open

Open a file from disk into a tab.

ParameterTypeRequired
filePathstringYes
windowLabelstringNo

Returns {tabId}.

open_workspace

Open a folder as the active workspace. Unlike open (a single file inside an already-consented tree), this grants the assistant access to a whole new file tree, so it is gated by a one-time user approval and is not covered by the path scope above.

ParameterTypeRequired
folderPathstringYes
windowLabelstringNo

Approval flow. The first call returns {needsApproval: true} and raises a consent dialog naming the canonical folder path (symlinks resolved). The assistant should ask the user, then retry the same call; once the user approves, the retry opens the folder. A denied request keeps failing until it is re-approved. There is no "remember" option — each open is approved individually.

save

Save a tab to its existing path.

ParameterTypeRequired
tabIdstringNo (defaults to focused)

Returns {filePath, revision}.

save_as

Save a tab to a new path.

ParameterTypeRequired
tabIdstringNo
filePathstringYes

Returns {revision}.

Saving to a path other than the tab's own current file is treated as a new write. When Auto-approve edits (Settings → Integrations) is off (the default), such a request is refused with APPROVAL_REQUIRED and a toast tells you what was blocked. Saving back to the tab's own path is always allowed.

close

Close a tab. Refuses to discard unsaved work without force.

ParameterTypeRequired
tabIdstringYes
forcebooleanNo

Returns {closed: true} on success, {closed: false, reason: "DIRTY"} if the tab is dirty and force was not supplied.

switch_tab

Activate a tab.

ParameterTypeRequired
tabIdstringYes

focus_window

Focus a window.

ParameterTypeRequired
windowLabelstringYes

document

Read, write, transform. The spine of the surface.

read

ParameterTypeRequired
tabIdstringNo (defaults to focused)

Returns {content, revision, filePath, kind, dirty}. Always read before writing — the revision token must accompany the next write.

write

Replace full document content.

ParameterTypeRequiredDescription
tabIdstringNoTarget tab (defaults to focused)
contentstringYesNew full content
expected_revisionstringNoRevision token from the most recent read

If expected_revision is supplied and the document has changed since that read, the response is a STALE structured-error envelope with the current revision; re-read and retry.

json
// success
{ "revision": "rev-newAfterWrite" }

// stale
{ "error": "STALE", "message": "Document has changed since the last read", "current_revision": "rev-currentNow" }

transform

Apply a deterministic rewrite. Currently supports CJK-specific transforms (full-width ↔ ASCII punctuation conversion, CJK ↔ Latin spacing).

ParameterTypeRequiredDescription
tabIdstringNoTarget tab
kindstringYes"cjk-format", "cjk-spacing", or "cjk-punctuation"
expected_revisionstringNoConcurrency token

cjk-format applies the user's CJK formatting settings end-to-end. cjk-spacing inserts single spaces between CJK characters and adjacent Latin/digits. cjk-punctuation converts ASCII punctuation that sits beside CJK characters to its full-width form.

Returns {revision}.


workflow

actionlint validation and CST-safe surgical edits for GitHub Actions workflow YAML. Available only for tabs whose kind is "yaml-workflow".

document.read / document.write work on every tab — including workflow YAML

The workflow tool is not a substitute for the read/write spine. For a workflow tab, you can:

  • document.read to get the raw YAML text (with all comments)
  • document.write to replace it wholesale (whatever string you send is stored verbatim — comments preserved if you include them)
  • workflow.apply_patch when you want the server itself to guarantee that comments, anchors, and key order survive a partial edit

Use apply_patch when changing one field and leaving everything else untouched (the server can't drop comments it doesn't change). Use document.write when you're rewriting wholesale or generating a new workflow from scratch.

apply_patch

Apply an array of IRPatch objects. Patches are dispatched through VMark's CST-aware mutators, which preserve comments, anchors, and key order. Raw document.write to a YAML file would lose them.

ParameterTypeRequired
tabIdstringNo
patchesIRPatch[]Yes
expected_revisionstringNo

IRPatch is a discriminated union (kind field). Supported kinds:

kindEffect
workflow.setSet top-level fields ({path, value}) — name, env.X, etc.
job.setSet a field on a job ({jobId, path, value})
step.setSet a field on a step ({jobId, stepIndex, path, value})
with.setSet a key in a step's with: block ({jobId, stepIndex, key, value})
with.removeRemove a key from a step's with: block
needs.add / needs.removeAdd or remove a job ID from needs:
trigger.setFiltersReplace a trigger filter array — branches, paths, types, etc. ({event, filter, value: string[]})

Returns {revision} on success or a structured STALE / INVALID_PATCH / NOT_WORKFLOW error envelope.

validate

Run actionlint over the workflow YAML.

ParameterTypeRequired
tabIdstringNo

Returns {ok, diagnostics, binaryAvailable}. Each diagnostic carries {line, col, message, severity}. binaryAvailable: false means actionlint is not installed locally; install via Homebrew or upstream releases.


selection

Read or replace the user's current editor selection. Use this instead of document.read/document.write when the user has highlighted the region to change — selection.get returns just the selected slice, and selection.set rewrites just that range, so token cost scales with the edit, not the document.

Selection is view-state — focused tab only

The selection only exists in the editor that's currently rendered. If tabId is supplied it must match the focused tab; mismatch returns INVALID_TAB. If the focused tab has no live editor (e.g. read-only viewer), the response is NO_EDITOR.

get

ParameterTypeRequired
tabIdstringNo

Returns:

FieldTypeNotes
textstringMarkdown serialization of the selected slice (WYSIWYG mode), or raw selected text (source mode). Empty string when collapsed.
isEmptybooleantrue when the selection is collapsed (cursor only).
range{from, to}ProseMirror positions in WYSIWYG mode; character offsets in source mode.
mode"wysiwyg" | "source"Disambiguates the position space of range.
kind"markdown" | "yaml-workflow"Document kind discriminator.
tabIdstringEchoed for confirmation.
revisionstringPass back into set for optimistic concurrency.

set

ParameterTypeRequired
tabIdstringNo
contentstringYes
expected_revisionstringNo (recommended)

Replaces whatever the editor reports as the current selection. In WYSIWYG mode, plain inline text inserts as a literal text node so leading/trailing whitespace round-trips exactly; content carrying markdown markers (**bold**, *italic*, `code`, fenced code, blockquotes, lists, etc.) is parsed as markdown and inserted as the corresponding nodes. In source mode, content is always spliced as raw text — the source surface is already markdown bytes. Empty content deletes the selection. When the selection is collapsed, content is inserted at the cursor.

Returns {revision, replaced_chars} on success. replaced_chars is the length of the text that was selected before the call — useful for the AI to confirm it edited what it expected.

STALE returns {error: "STALE", message, current_revision} exactly like document.write. The doc-level revision catches keystrokes between get and set. Pure cursor movement (without a keystroke) is not arbitrated by the server — if the user moved the cursor between get and set, the edit lands at the new position.


browser

The browser tool is available only when Settings → Advanced → Embedded browser is enabled. All six actions fail with BROWSER_DISABLED while it is off. URLs returned to MCP are redacted through the same boundary used by the app's browser session state.

read

Returns {url, snapshot} for the focused browser tab, or the tab named by tabId. snapshot is an ARIA-oriented list of {role, name, ref} — each ref (e.g. "e5") is a stable handle for that element, valid for the life of the current view.

act

Arguments: tabId?, operation: "click" | "type" | "scroll" | "key", and per-operation targets:

  • click / type — a target, either ref (from a prior read) or role + name, and text? for typing. A ref is precise and order-independent but is only honored for an already-granted operation; if the action may need approval, use role + name so the prompt shows the user a readable element.
  • scrollref (scroll it into view) or dy (a vertical pixel delta).
  • keykey (e.g. "Enter", "Escape", "Tab"), optional ref to target, and optional modifiers: {ctrl, shift, alt, meta}.

scroll and key are act-class (approval-gated) and dispatch synthetic DOM events, so a site gating on event.isTrusted may ignore them. Mutating operations require an origin-scoped approval; AI-chosen uploads are never permitted.

open

Arguments: url and optional timeoutMs (1–12,000 ms). Creates an AI-owned tab using the current Sandbox or Shared posture and returns its tabId, navigationId, URL, title, and generation after the load completes.

Arguments: tabId?, url, and optional timeoutMs. Navigates an AI-owned tab and returns the navigation ticket result. A timeout still returns the ticket so a later wait can retrieve the terminal result.

wait

Arguments: tabId?, optional navigationId, and optional timeoutMs. It never starts a navigation. It returns a buffered load/failure result, NAVIGATION_SUPERSEDED, or TIMEOUT when the ticket does not finish within the bound.

wait_for

Arguments: tabId?, exactly one of ref (from a read), role (+ optional name), or text (a substring of visible text), and optional timeoutMs (1–12,000 ms). Polls until the condition holds or the timeout elapses and returns {matched: true|false} (plus the matched element's ref for a ref/role condition) — so you can tell "found" from "timed out". Read-class. Use it to make a flow deterministic: act, wait_for the result, then read.

query

Arguments: tabId?, selector (CSS), and optional fields: {attributes, box, styles:[...]}. Returns {count, elements: [{ref, tag, text, …}]} — structured DOM data the ARIA snapshot cannot name (tables, computed values). Read-class. Runs in the isolated content world.

style

Arguments: tabId?, a target (ref or selector), and one of set: {prop: value}, addClasses, removeClasses, or injectCss. Dismiss a blocking overlay, highlight a target, etc. Act-class (approval-gated, op style). Isolated content world.

execute_js

Arguments: tabId?, script (must return a JSON-serializable value). The escape hatch for what the structured verbs cannot express. It runs in the isolated content world — it shares the DOM (so querySelector, element.style work) but cannot see the page's own JS heap/globals. It is approved per call only (never a standing grant, enforced in the Rust driver), the approval shows the script, and the return value is flagged untrusted and never auto-fed into a later act. Prefer query/style first.

session_save / session_load

Arguments: tabId?, handle ([A-Za-z0-9._-], 1–128 chars). session_save snapshots the tab's session into an OS-keychain entry named by handle and returns a value-free summary (counts); session_load restores it and returns {loaded: true, handle} — a confirmation plus the AI-supplied handle, never any values. A session_load only applies to a page with the same origin the session was saved from. This is credential-by-reference (ADR-A7): the AI names a saved session and never receives cookie/token values, which are never logged. Both are the session permission — never a standing grant (approved per call), and an approval for one handle cannot be spent on another. Today this covers localStorage; cookie capture is a live-testing follow-up.

console

Arguments: tabId?, clear?. Returns {entries: [{level, text}], url} — the page's captured console.* output. Read-class, sandbox-tabs only. The capture works by a page-world shim that writes into a hidden DOM buffer which the driver reads from the isolated world — so no messaging channel is opened back into VMark (the no-bridge guarantee holds). The output is page-controlled and untrusted — treat it like a read, never as an act target. Pass clear: true to drain the buffer as you read it.

screenshot

Arguments: tabId?. Returns an image content block (base64 JPEG, quality-bounded) of the tab's current rendering, plus a text line naming the page — a visual channel onto layout and rendered state the ARIA snapshot cannot describe. It is captured natively (takeSnapshot) and reads no page DOM or JavaScript. Read-class: authorized exactly like read (allowed on an AI-owned tab; a human tab needs an attachment, consumed on capture).

Shared posture asks for destination approval for every new origin unless a matching navigate grant exists. A human-created tab requires an ephemeral attachment approval before AI read/act. Sandbox tabs use a separate non-persistent AI cookie store.


coherence

A read-only view of the workspace coherence layer — which derived documents are stale against the upstreams they were generated from. Neither action modifies documents or editor state. status is read-only; edges reconciles first and may append provenance records to the workspace ledger, but never changes document content. Both are answered entirely by the Rust backend from the per-workspace kernel, so they work even when no editor window is in the foreground.

Two further read-only actions expose the semantic layer:

  • claims — the current canon claims: {claim, entryId, statement, maturity, invalidAt, visible}. Only established claims constrain semantic checks; visible reflects the default context.
  • contexts — the context set (the implicit default is always present): {id, name, parent, enforcement, visibleClaims, errors}.

One mutating action, gated by delegation:

  • resolve — resolve a live stale edge as an explicitly delegated agent: {workspace_root, txf, input, resolution: "accept-newer" | "waive", reason? (required for waive)}. Authorization is fail-closed: the workspace owner must have granted your authenticated bridge identity a live, unexpired delegation covering the resolution kind (granted in-app, from the Breakdown), and the edge must be live. Every delegated resolution is audit-logged against the grant. Claim and context mutation are never exposed — canon stays human-controlled.

All actions require workspace_root: the absolute path of the workspace to query. Learn it from session.get_state (open tabs' filePath) or the workspace tool. A path that is missing, not absolute, or not a directory is refused with a plain-string error.

status

Kernel status counters for one workspace.

ParameterTypeRequiredDescription
workspace_rootstringYesAbsolute path of the workspace to query

Returns:

json
{
  "initialized": true,
  "objects": 12,
  "open_items": 2,
  "quarantined": 0,
  "writer": "0198c0de-0000-7000-8000-000000000001"
}
FieldMeaning
initializedfalse when the workspace has no coherence ledger yet (no .vmark/ directory). All counters except objects are 0 in that case.
objectsTracked objects (files with a coherence identity).
open_itemsLive, non-fresh edges — the current breakdown size.
quarantinedMalformed ledger lines quarantined on the last read.
writerThis installation's writer id (UUID).

edges

The breakdown: every live dependency edge whose upstream has moved. Runs a scan-reconcile first, so the answer reflects the files on disk at call time.

ParameterTypeRequiredDescription
workspace_rootstringYesAbsolute path of the workspace to query

Returns an array — empty when everything is coherent:

json
[
  {
    "txf": "0198c0de-0000-7000-8000-00000000000a",
    "input": 0,
    "upstream": "0198c0de-0000-7000-8000-00000000000b",
    "upstream_path": "characters/elena.md",
    "pinned": "rev-a1b2c3",
    "downstream": "0198c0de-0000-7000-8000-00000000000c",
    "downstream_path": "scenes/chapter-3.md",
    "downstream_rev": "rev-d4e5f6",
    "state": "version-stale"
  }
]
FieldMeaning
txf / inputThe transformation entry and input slot identifying this edge (pass these to the in-app resolution actions).
upstream / upstream_pathThe object the downstream depends on, and its last-known path.
pinnedThe upstream revision the downstream was generated from.
downstream / downstream_path / downstream_revThe derived object, its path, and its current revision.
state"version-stale", "stale-valid", "stale-contradicted", "stale-unknown", "waived", "diverged", "diverged-multi-head", or "unpinnable".

Resolving an edge (accept-newer / waive) is a human action performed in VMark's breakdown view — it is deliberately not exposed over MCP.


Errors

Two error shapes appear:

Domain errors — set success: false and return a JSON-encoded envelope in error:

json
{ "error": "STALE", "message": "...", "current_revision": "rev-..." }

Argument-shape errors — for missing/invalid required arguments (e.g., document.write without a content field), error is a plain string describing the problem. The structured envelope is reserved for domain-level conditions.

CodeSurfaced asMeaning
STALEenvelopeexpected_revision did not match; re-read and retry
INVALID_PATCHenvelopeworkflow.apply_patch received a malformed patches array
INVALID_TABenvelopetabId could not be resolved
INVALID_PATHenvelopeA filePath could not be read, or is outside the open workspace / document scope
APPROVAL_REQUIREDenvelopesave_as to a new location while Auto-approve edits is off
NOT_WORKFLOWenvelopeworkflow.* was called on a non-YAML-workflow tab
READ_ONLYenvelopeA mutation was attempted on a read-only document
NO_EDITORenvelopeselection.* was called but the focused tab has no live editor
INTERNALenvelopeUnexpected handler error
(plain string)stringRequired argument missing or wrong type