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
- Call
session.get_stateonce to see open windows, tabs, and per-tab{filePath, dirty, revision, kind}. - For small Markdown changes or wholesale rewrites:
document.read→ reason →document.write(passingexpected_revisionfor safe concurrency). - 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). - For GitHub Actions YAML (
kind: "yaml-workflow"):workflow.apply_patchfor CST-safe edits that preserve comments and anchors;workflow.validatefor actionlint diagnostics. - 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}:
{
"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 withINVALID_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.
| Parameter | Type | Required | Description |
|---|---|---|---|
kind | string | No | "markdown" (default) or "yaml-workflow" |
windowLabel | string | No | Target window; defaults to focused |
Returns {tabId}.
open
Open a file from disk into a tab.
| Parameter | Type | Required |
|---|---|---|
filePath | string | Yes |
windowLabel | string | No |
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.
| Parameter | Type | Required |
|---|---|---|
folderPath | string | Yes |
windowLabel | string | No |
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.
| Parameter | Type | Required |
|---|---|---|
tabId | string | No (defaults to focused) |
Returns {filePath, revision}.
save_as
Save a tab to a new path.
| Parameter | Type | Required |
|---|---|---|
tabId | string | No |
filePath | string | Yes |
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.
| Parameter | Type | Required |
|---|---|---|
tabId | string | Yes |
force | boolean | No |
Returns {closed: true} on success, {closed: false, reason: "DIRTY"} if the tab is dirty and force was not supplied.
switch_tab
Activate a tab.
| Parameter | Type | Required |
|---|---|---|
tabId | string | Yes |
focus_window
Focus a window.
| Parameter | Type | Required |
|---|---|---|
windowLabel | string | Yes |
document
Read, write, transform. The spine of the surface.
read
| Parameter | Type | Required |
|---|---|---|
tabId | string | No (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.
| Parameter | Type | Required | Description |
|---|---|---|---|
tabId | string | No | Target tab (defaults to focused) |
content | string | Yes | New full content |
expected_revision | string | No | Revision 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.
// 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).
| Parameter | Type | Required | Description |
|---|---|---|---|
tabId | string | No | Target tab |
kind | string | Yes | "cjk-format", "cjk-spacing", or "cjk-punctuation" |
expected_revision | string | No | Concurrency 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.readto get the raw YAML text (with all comments)document.writeto replace it wholesale (whatever string you send is stored verbatim — comments preserved if you include them)workflow.apply_patchwhen 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.
| Parameter | Type | Required |
|---|---|---|
tabId | string | No |
patches | IRPatch[] | Yes |
expected_revision | string | No |
IRPatch is a discriminated union (kind field). Supported kinds:
kind | Effect |
|---|---|
workflow.set | Set top-level fields ({path, value}) — name, env.X, etc. |
job.set | Set a field on a job ({jobId, path, value}) |
step.set | Set a field on a step ({jobId, stepIndex, path, value}) |
with.set | Set a key in a step's with: block ({jobId, stepIndex, key, value}) |
with.remove | Remove a key from a step's with: block |
needs.add / needs.remove | Add or remove a job ID from needs: |
trigger.setFilters | Replace 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.
| Parameter | Type | Required |
|---|---|---|
tabId | string | No |
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
| Parameter | Type | Required |
|---|---|---|
tabId | string | No |
Returns:
| Field | Type | Notes |
|---|---|---|
text | string | Markdown serialization of the selected slice (WYSIWYG mode), or raw selected text (source mode). Empty string when collapsed. |
isEmpty | boolean | true 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. |
tabId | string | Echoed for confirmation. |
revision | string | Pass back into set for optimistic concurrency. |
set
| Parameter | Type | Required |
|---|---|---|
tabId | string | No |
content | string | Yes |
expected_revision | string | No (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) orrole+name, andtext?for typing. Arefis precise and order-independent but is only honored for an already-granted operation; if the action may need approval, userole+nameso the prompt shows the user a readable element. - scroll —
ref(scroll it into view) ordy(a vertical pixel delta). - key —
key(e.g."Enter","Escape","Tab"), optionalrefto target, and optionalmodifiers: {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.
navigate
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}. Onlyestablishedclaims constrain semantic checks;visiblereflects the default context.contexts— the context set (the implicitdefaultis 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.
| Parameter | Type | Required | Description |
|---|---|---|---|
workspace_root | string | Yes | Absolute path of the workspace to query |
Returns:
{
"initialized": true,
"objects": 12,
"open_items": 2,
"quarantined": 0,
"writer": "0198c0de-0000-7000-8000-000000000001"
}| Field | Meaning |
|---|---|
initialized | false when the workspace has no coherence ledger yet (no .vmark/ directory). All counters except objects are 0 in that case. |
objects | Tracked objects (files with a coherence identity). |
open_items | Live, non-fresh edges — the current breakdown size. |
quarantined | Malformed ledger lines quarantined on the last read. |
writer | This 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.
| Parameter | Type | Required | Description |
|---|---|---|---|
workspace_root | string | Yes | Absolute path of the workspace to query |
Returns an array — empty when everything is coherent:
[
{
"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"
}
]| Field | Meaning |
|---|---|
txf / input | The transformation entry and input slot identifying this edge (pass these to the in-app resolution actions). |
upstream / upstream_path | The object the downstream depends on, and its last-known path. |
pinned | The upstream revision the downstream was generated from. |
downstream / downstream_path / downstream_rev | The 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:
{ "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.
| Code | Surfaced as | Meaning |
|---|---|---|
STALE | envelope | expected_revision did not match; re-read and retry |
INVALID_PATCH | envelope | workflow.apply_patch received a malformed patches array |
INVALID_TAB | envelope | tabId could not be resolved |
INVALID_PATH | envelope | A filePath could not be read, or is outside the open workspace / document scope |
APPROVAL_REQUIRED | envelope | save_as to a new location while Auto-approve edits is off |
NOT_WORKFLOW | envelope | workflow.* was called on a non-YAML-workflow tab |
READ_ONLY | envelope | A mutation was attempted on a read-only document |
NO_EDITOR | envelope | selection.* was called but the focused tab has no live editor |
INTERNAL | envelope | Unexpected handler error |
| (plain string) | string | Required argument missing or wrong type |