Set up and run the VisiMark MCP server
From nothing on the machine to an agent that can check, plan and — when you let it — fix the arithmetic in your Markdown.
This guide is about one package: visimark-mcp. It is the same VisiMark
engine as the CLI and the CI Action, wearing a different transport — instead
of an exit code, it speaks MCP over stdio,
so an agent can call it as a tool.
It is written for someone who has not run an MCP server before. You do not
need to know VisiMark's language to get through Part 2; tutorial.md
is there for the language itself, and mcp.md is the complete
reference this guide keeps pointing at rather than repeating.
Every transcript here is a real one — either captured from this repository's
own test suite (packages/visimark-mcp/test/) or from the handshake script CI
runs against the packed package (scripts/mcp-handshake.mjs). Nothing is
invented.
How to read this
There are 20 short chapters in six parts. Part 1 says why this exists at all. Part 2 gets a server running and answering in a few minutes. The rest answers the questions that turn up once you are actually using it.
| Part | Chapters | What you get |
|---|---|---|
| 1. What this is, and what it is not | 1–2 | The shape of the thing, and its one hard boundary |
| 2. The five-minute setup | 3–6 | A running server your agent can talk to |
| 3. The six read tools | 7–11 | check, eval, explain, infer, fmt, ref |
| 4. Turning on writes | 12–15 | The gate, the plan/apply split, and what refuses it |
| 5. What the server teaches the agent | 16–17 | Resources and prompts |
| 6. Operating it | 18–20 | Runtimes, versions, and what still belongs in CI |
Part 1 — What this is, and what it is not
1. Why a CLI needed a second front door
visimark check is a command a shell runs and a person reads. An agent
working in a repository it has never seen has neither: no shell it is safe to
hand arbitrary commands to, and no reason to already know a CLI called
visimark exists.
MCP is the standard answer to that gap — a host (an editor, an agent
framework, a chat client) declares a server, the server declares tools, and
the model calls them the way it calls any other tool. visimark-mcp is
VisiMark's half of that: it imports the same engine the CLI does — never
shells out to it — and serves every command as a tool, the authoring
discipline as resources, and two orderings agents reliably get wrong as
prompts.
The "imports, never shells out" part matters for one concrete reason: a
document an agent is drafting in context — not yet saved anywhere — can be
checked as a string. There is no temp file to create, no path to make up, no
cleanup to remember. Every read tool takes content exactly as readily as
path.
2. The one thing it will not do for you
It does not replace visimark check in CI, and it is not a gate.
A gate is something that fails a build. visimark-mcp is something an agent
consults — and an agent that can read a verdict can also decide not to act
on it, or forget to ask in the first place. Keep visimark check **/*.md in
your CI workflow (ci.md is the guide) no matter how thoroughly your
agent uses this server. This guide will not repeat that boundary again, but
everything in it is built on top of it.
The second thing worth knowing before you install anything: it is read-only by default, and deliberately hard to make otherwise. Chapter 12 is the whole story; until then, assume nothing you do through this server touches a file on disk.
Part 2 — The five-minute setup
3. Install it
Runtime parity applies here exactly as it does to the engine itself: install with either package manager, run with either runner.
$ npm i -g visimark-mcp
$ bun add -g visimark-mcp
or run it without installing anything:
$ npx visimark-mcp
$ bunx visimark-mcp # a machine with Bun but no Node
The installed command runs under whichever of Bun or Node is on your PATH.
On Windows, npm's global shim needs sh on PATH — Git Bash or WSL provide
it, plain PowerShell does not.
There is exactly one flag, and you do not need it yet:
visimark-mcp [--allow-write]
An unrecognised option is refused with exit 2 before the transport starts —
the same discipline as the CLI. Once stdio is the protocol stream there is
nowhere left to print a usage line, so the check happens first.
4. Point a host at it
The transport is stdio, so "pointing a host at it" always means the same thing: a command the host spawns and talks to over its stdin/stdout. Every host's configuration is a thin skin over that one fact.
Claude Code, from a shell:
$ claude mcp add visimark -- npx -y visimark-mcp
$ claude mcp add visimark -- bunx visimark-mcp # a machine with no Node
Any host that reads an mcpServers object — Claude Desktop's
configuration file, and several other agent clients follow the same shape:
{
"mcpServers": {
"visimark": { "command": "npx", "args": ["-y", "visimark-mcp"] }
}
}
Swap npx/-y visimark-mcp for bunx/visimark-mcp on a Bun-only machine,
and add "args": ["-y", "visimark-mcp", "--allow-write"] once you have read
chapter 12 and actually mean it.
Whatever the host, the same three facts are what you are configuring: a command, its arguments, and nothing else. There is no environment variable, no config file and no network address — the server reads only what the host tells it and the documents the agent hands it.
5. Prove it is actually alive
Before you trust a host's UI to tell you the server started, talk to it
yourself. This is the exact exchange CI runs against every release, trimmed to
the two calls that matter — and it is worth doing once by hand, the same way
chapter 4 of ci.md has you break a document on purpose before
trusting a check.
MCP over stdio is JSON-RPC, one message per line. initialize first:
→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"me","version":"0.0.0"}}}
← {"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"visimark","version":"0.1.8"}, …}}
serverInfo.name is always "visimark"; serverInfo.version is the
server's version — a different number from the engine version you will
see in every tool result. Chapter 19 is why they can differ and why that is
fine.
Then notifications/initialized, and tools/list:
→ {"jsonrpc":"2.0","method":"notifications/initialized"}
→ {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
← … eight tools, named visimark_check, visimark_eval, visimark_explain,
visimark_fmt, visimark_fmt_apply, visimark_infer, visimark_infer_apply,
visimark_ref …
If your host has no console for this, the repository's own smoke test does exactly this exchange, with no dependency on anything else in the repo:
$ node scripts/mcp-handshake.mjs "$(which visimark-mcp)"
mcp-handshake: ok — 8 tools, server 0.1.8, engine 0.1.8, and visimark_check ran
$ bun scripts/mcp-handshake.mjs "$(which visimark-mcp)"
Eight tools listed and a version number back is the whole test. Nothing about the eight tools themselves matters yet — that is Part 3.
6. Ask it about a real document
One tool call, end to end, against a document already in this repository —
docs/example-invoice.md, which is clean, and its deliberately-broken sibling
docs/example-invoice-drift.md (the same one example-invoice-drift.md
walks through by hand).
→ {"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"visimark_check","arguments":{"path":"docs/example-invoice.md"}}}
← {"command":"check","visimark":"0.1.8","status":"ok","findings":[],"skipped":{}}
→ {"jsonrpc":"2.0","id":4,"method":"tools/call",
"params":{"name":"visimark_check","arguments":{"path":"docs/example-invoice-drift.md"}}}
← {"command":"check","visimark":"0.1.8","status":"problems",
"summary":{"problems":26,"stale":21,"errors":5}, "findings":[ … ] }
Read that second response carefully, because it is the one thing about this
server that is easy to get backwards: status: "problems" is a successful
tool call. The MCP layer reports no error, isError is not set, and the
agent gets exactly what it asked for — a document that disagrees with itself,
described precisely. A document with findings is not a broken server, in the
same way visimark check's exit code 1 on the CLI is not a crash. Chapter
11 spells out the whole mapping.
Try it with no path at all — a table you are drafting in the conversation, that exists nowhere on disk:
→ {"jsonrpc":"2.0","id":5,"method":"tools/call",
"params":{"name":"visimark_check",
"arguments":{"content":"| Item | Qty |\n|---|---:|\n| a | 1 |\n"}}}
← {"command":"check","status":"problems","file":"<content>","skipped":{},
"findings":[{"code":"COVERAGE", …}]}
file comes back as the literal string "<content>" rather than a made-up
path — there was never a path to report. This table has no vmark rule at
all, so the COVERAGE finding is correct: nothing in it is being checked.
Chapter 10 of ci.md is the full explanation of that finding; here,
the point is narrower — you just verified an in-progress draft with no file
ever touching disk.
Part 3 — The six read tools
7. path or content, never both, never neither
Every read tool takes either path or content — this is the one
place the surface deliberately does not mirror the CLI, because an agent
usually has a table in context, not a file.
| Input | What resolves |
|---|---|
path only |
imports, artifacts and staleness all resolve normally |
content only |
filesystem-dependent phases stand down — see below |
| both | a USAGE fault, never a silent preference for one |
| neither | a USAGE fault |
visimark_eval's scenario argument follows the identical rule with
scenarioPath and scenarioContent, so a draft document can be evaluated
against a draft scenario without either one touching disk:
→ {"name":"visimark_eval",
"arguments":{"path":"docs/example-agent-budget.md",
"scenarioContent":"{\"budget\":\"5.00\"}"}}
← {"command":"eval","status":"ok",
"scenario":{"file":"<scenarioContent>",
"params":{"rates.budget":{"value":"5","default":"2","source":"scenario"}}}, …}
The apply tools in Part 4 take path only — there is nothing to apply an edit
to inside a string.
What content mode cannot see
A document handed over as content has no directory, so a declared import
or chart target cannot resolve. This is neither an error nor silent — it is
recorded:
{ "skipped": { "imports": ["ledger"], "charts": ["costs.trend"] } }
skipped is {} when nothing was skipped, so its absence never has to be
inferred — skipped.charts present always means "these could not be
checked", and its absence always means "none were".
8. visimark_check — does the document agree with itself?
This is the one you already saw in chapter 6. Two more things worth knowing:
An unreadable path is a different failure class from a document with
findings. path naming a file that does not exist comes back as a READ
fault — a tool error, not a result:
→ {"name":"visimark_check","arguments":{"path":"docs/nope.md"}}
← tool error — {"code":"READ", "message": …}
Passing both path and content, or neither, is USAGE — the same class
as a name that does not exist, covered in chapter 11's table.
9. visimark_eval — read a computed value straight out of a document
eval is how a document becomes something a workflow can read rather than
only something a person checks. get names a value, sheet.name or a bare
name where it is unambiguous:
→ {"name":"visimark_eval",
"arguments":{"path":"docs/example-invoice.md","get":"lines.gross_total"}}
← {"command":"eval","status":"ok","values":{"lines.gross_total":"…"}}
That value always comes back as a decimal string, never a JSON number —
JSON numbers are IEEE floats, and money is not one. Asking for a name the
document does not have is USAGE, the same as the CLI:
← {"code":"USAGE","message":"visimark: no value named nope"}
A scenario that names an undeclared parameter, or a value that does not fit
its type, is its own fault class, SCENARIO — distinct from USAGE because
the request made sense and the data did not.
10. visimark_explain and visimark_ref — read the document's shape, or the language's
explain describes a document's sheets without evaluating anything —
useful when an agent wants to know what is in a document before deciding
what to ask it:
→ {"name":"visimark_explain","arguments":{"path":"docs/example-invoice.md"}}
← {"command":"explain","status":"ok","sheets":[ … ]}
Naming a sheet that does not exist is, once again, USAGE:
"visimark: no sheet #nope".
visimark_ref is the odd one out in the table: it takes no file at all.
It is the function reference — every VisiMark builtin, or one named function:
→ {"name":"visimark_ref","arguments":{"name":"SUM"}}
← {"function":{"name":"SUM","signature": …}}
An unknown name gets exactly the CLI's own wording, did-you-mean included when one exists:
← {"code":"USAGE","message":"visimark: unknown function `SUMM` — did you mean `SUM`?"}
11. visimark_infer and visimark_fmt — the two tools that only plan
infer reads numbers already in a document and proposes the rules that
reproduce them, exactly, at each column's own precision:
→ {"name":"visimark_infer",
"arguments":{"content":"| Item | Qty | Rate | Net |\n|---|---:|---:|---:|\n| a | 2 | 3.00 | 6.00 |\n"}}
← {"command":"infer","status":"ok","proposals":[ … ]}
fmt reads a document and returns the edits that would repair every STALE
finding — and, like infer, writes nothing:
→ {"name":"visimark_fmt","arguments":{"path":"docs/example-invoice-drift.md"}}
← {"command":"fmt","status":"problems","applied":false,
"edits":[ … ], "sha256":"e3b0c442…", "artifactsWouldWrite":[ … ]}
Two details make that response worth reading carefully:
applied is always false from this tool, never omitted and never
true. There is a second tool for applying — chapter 13 — and the two never
collapse into one call, so an agent cannot accidentally write by calling the
wrong one with the right arguments.
sha256 is the hash of the bytes this plan was computed against. It
travels with the plan into the apply call, and chapter 14 is entirely about
what happens when it no longer matches.
Both infer and fmt carry readOnlyHint: true in their tool annotation —
planning is not writing, and the annotation says so at the protocol level, not
only in prose.
Part 4 — Turning on writes
12. The write gate: two conditions, neither reachable from a tool call
Writes are off unless both hold:
- the server was started with
--allow-write, and - the host declared at least one MCP root.
No roots means no writes, even with the flag. An operator who passes
--allow-write into a host that declares nothing has not chosen where writes
are allowed, and the server refuses to choose for them.
Neither half of that is something a tool call can set. Roots come only from the MCP protocol itself — the host lists them once the connection completes, and again whenever it notifies the server the list changed — never from an argument a model supplies. An agent cannot argue its way past a flag it cannot pass or a root it cannot declare.
The two apply tools — visimark_fmt_apply, visimark_infer_apply — are
listed even when writes are disabled. Calling one with the gate shut
returns a tool error, not silence:
writes are disabled. Start the server with --allow-write.
Listed-and-erroring rather than hidden, so a human looking at the error text
knows exactly what to do about it. Both apply tools carry
destructiveHint: true regardless of the gate's state, because the
annotation describes what the tool is, not what today's session happens to
allow.
This sits on top of a second gate that never turns off: containment in the document's own directory, and a refusal to overwrite any file that does not already carry VisiMark's own metadata marker — which is what stops an artifact write from clobbering a hand-drawn SVG that happens to share a filename.
13. visimark_fmt_apply — the plan you already reviewed, and only that plan
fmt_apply takes back the exact object fmt returned:
→ {"name":"visimark_fmt_apply",
"arguments":{"path":"docs/example-invoice-drift.md","plan":{ "sha256":"e3b0c442…", … }}}
← {"command":"fmt","applied":true,"changed":true,
"cellsUpdated":<n>,"anchorsUpdated":<n>}
cellsUpdated and anchorsUpdated match the plan's own counts exactly — the
plan is what gets applied, not a fresh re-derivation of what should be
applied. A follow-up visimark_check on the same path now reports
summary.stale: 0: every number the plan named as stale is fixed. (A
document can carry other finding kinds fmt never touches — a cycle, an
unknown name, an unparseable date — so stale: 0 is not the same claim as
"this document has no problems left".)
Drop the plan's sha256 before calling apply, and the request is refused as
USAGE — a plan is not optional metadata, it is the thing being applied.
14. What happens when the file moved under the plan
Apply the same plan twice, or apply it after anything else touched the file:
visimark: … changed since this plan was computed
That is a WRITE fault, and it is the entire reason the split exists. A
PlannedEdit carries byte offsets computed against one specific version of
the file; splicing those offsets into a file that has since changed would
corrupt it silently. The digest check refuses instead of guessing.
The apply tools do not silently re-plan on a mismatch. That could not go stale, technically — but it would mean the plan an agent (or a human reading its call) reviewed is not the one that actually landed, which defeats the point of separating plan from apply in the first place. A stale plan is an error to surface, never a difference to paper over.
15. Artifacts: what a chart adds to the apply story
A document can declare a chart, an SVG rendered from its own sheet's
columns. fmt's plan lists every artifact path it would write
(artifactsWouldWrite); fmt_apply re-derives the actual bytes from the
digest-proved-identical source, and refuses before touching the document if
any target in that list can no longer be written:
→ … plan.artifactsWouldWrite still lists an SVG the document's chart declares,
but a plan you edited to say the artifacts array is empty …
← {"code":"WRITE","message":"… was not in the plan"}
The document is left completely unmodified on that refusal — every
refusal that can be decided before a byte moves is decided first, and then
artifacts are written before the document is spliced, so one rejected target
never leaves half a fmt applied. fmt --no-artifacts exists on the CLI to
decline writing an artifact from a read-only context; the MCP tools carry no
equivalent flag, because the plan/apply split already gives an agent the
chance to inspect artifactsWouldWrite before ever calling apply.
visimark_infer_apply takes visimark_infer's result the same way: drop any
proposal you rejected before calling apply, and only the survivors are
inserted. A plan whose every proposal was rejected inserts nothing — and, in
particular, does not mark the document no-formulas on your behalf.
Part 5 — What the server teaches the agent
16. Resources: the discipline, not only the tool
Six tools tell an agent how to ask a question. The resources tell it why the answers matter, and they are read from the installed package's own files — never fetched over the network at request time.
| URI | Serves |
|---|---|
visimark://skill |
the authoring discipline itself — read this one first |
visimark://cli-reference |
every command, option, exit code and finding kind |
visimark://function-reference |
the builtins |
visimark://example/invoice |
a complete worked invoice |
visimark://example/drift |
the same invoice after one input changed and nothing derived from it followed |
The highest-value resource here is not any of the four reference documents —
it is the skill. A server that ships check without the reasoning behind it
hands an agent a verifier with none of the discipline that makes the verifier
worth anything, chief among them the rule this whole guide keeps repeating in
different words: a passing check is evidence of agreement, never evidence
of derivation.
That skill text is generated from this repository's own
skills/visimark/SKILL.md at build time, with
its "Running it" section rewritten for a reader who has the server installed
and not the repository checked out. CI regenerates both the skill and the
reference doc copies the server ships and fails on any diff, so what an agent
reads through visimark://skill cannot quietly drift from what this
repository actually teaches.
17. Prompts: the two orderings agents get wrong unsupplied
| Name | The ordering it encodes |
|---|---|
visimark/take-over |
adopting an existing document: run infer first, never hand-author rules for numbers that are already there |
visimark/author |
writing a new one: table, then a vmark block, then anchors, then prose, then fmt — in that order |
Two prompts, deliberately, and not three. A prompt earns its place when it
encodes an ordering an agent reliably gets wrong without it, and VisiMark has
exactly two such orderings. The habit from chapter 4 of ci.md —
change one input on purpose and confirm the document actually reacts — is a
step inside both prompts rather than a third prompt of its own, because
splitting it out would let an agent treat it as optional. It is not.
Part 6 — Operating it
18. Node, Bun, and which one you are actually running
visimark-mcp needs the current Node LTS or newer, or Bun. Both npm i -g /
npx and bun add -g / bunx are first-class — this is the same runtime
parity the CLI carries, and for the identical reason: a bin entry that
assumed one runtime broke the published command for every user of the other
(issue #29 is the whole story, if you want it).
If you are testing a change to this repository itself rather than the published package, two things matter that a released install never has to think about:
$ bun run packages/visimark-mcp/src/main.ts # read-only
$ bun run packages/visimark-mcp/src/main.ts --allow-write
$ claude mcp add visimark-dev -- bun run "$(pwd)/packages/visimark-mcp/src/main.ts"
bunx visimark-mcp and npx visimark-mcp both run the published build,
silently. Neither will tell you that your local edit is not in it — you
will simply be testing last release's server. This is not a corner case
worth memorising for its own sake; it is the single most common way to "fix a
bug" and watch the fix not appear.
19. Two version numbers, on purpose
Every tool result's visimark field is the engine version. The MCP
initialize response's serverInfo.version is the server's version —
visimark-mcp's own package.json version, which pins an exact visimark
dependency rather than a range. They move in lockstep by release policy — one
tag bumps both — but the envelope is deliberately not widened to carry both
numbers, so nothing downstream has to guess which field means what.
If you ever see a mismatch between what you expected and what a fresh install
reports, it is a version question before it is anything else: which
visimark-mcp did you just install, and does npm view visimark-mcp version
agree with what initialize told you.
20. Troubleshooting
| Symptom | Likely cause | What to check |
|---|---|---|
| The host shows the server as failed to start | An unrecognised argument was passed | The server refuses before the transport opens; check the host's own log for the usage line on stderr |
writes are disabled. Start the server with --allow-write. |
The flag is missing, or the host declared no roots | Both conditions must hold — chapter 12 |
| A write tool's error names a path "outside every declared root" | The document lives outside every root the host declared | Point the host's root at the document's directory, or move the document |
… changed since this plan was computed |
The file moved between planning and applying | Call visimark_fmt (or infer) again and apply the fresh plan — chapter 14 |
| An artifact write is refused with "was not in the plan" or "can no longer be written" | The plan is stale, or something now occupies the artifact's path | Re-plan; if a real file already sits there, it is not VisiMark's to overwrite |
A visimark_check call with findings looks like "the server is broken" |
Findings are a successful call, not an error | Read status, not whether the call threw — chapter 6 and 8 |
bunx/npx seems to ignore a local change |
It ran the published package, not your working tree | Use bun run packages/visimark-mcp/src/main.ts from the repo instead — chapter 18 |
| The engine version in a result is not what you expected | The installed visimark-mcp pins an older engine |
Check npm view visimark-mcp@<version> dependencies.visimark, or reinstall the version you meant |
If a failure is genuinely confusing, the handshake script from chapter 5 reproduces the exact exchange CI itself uses to prove a release actually speaks the protocol — run it against your own installed binary before suspecting the host.
Where to go next
| Document | What it answers |
|---|---|
mcp.md |
The complete reference: every tool, every fault code, the full write-gate contract |
ci.md |
The CI gate this server does not replace |
tutorial.md |
The VisiMark language itself |
cli-reference.md |
The CLI this server wraps, command by command |
example-invoice-drift.md |
The document behind every drifted example in this guide, walked through by hand |