ashlr-md
AGENTS.md4 starsChanged 5 months agoMITView on GitHub
Ashlr MD ships an MCP server (mdopener-mcp) that lets AI coding agents open, read, edit, search, export, and request human review of the document that is live in your Ashlr MD window, without leaving their coding environment. It also ships mdopen, a tiny CLI companion for opening any .md file from a terminal or agent script, and for wiring up an auto-open hook that pops Markdown into Ashlr MD the moment your agent writes it. Tip: To watch everything an…
- Reads credentials
- Deletes or force-pushes
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,774 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repository5 other files it gives its agents.
- AGENTS.mdAGENTS.md
- landing/llms-full.txtllms.txt
- landing/llms.txtllms.txt
- llms-full.txtllms.txt
- llms.txtllms.txt
The file
Show its rules and text
We split this AGENTS.md into the rules it gives an agent
19 rules. About 0 are common, 2 are distinctive, and 17 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
17
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 99
When Claude Code or Codex already has an ashlr-md server, the Connect button asks it (mcp get) which program it runs.
- Distinctive: line 157
After running it, restart Claude Code (quit and start a new session) once.
- Very specific: line 220
If you prefer editing config directly, the codex mcp add command above writes the equivalent of this to ~/.codex/config.toml (global) or a …
- Very specific: line 273
If the file already has other servers, merge only the "ashlr-md" key into the existing "mcpServers" object.
- Very specific: line 276
After saving, go to Cursor Settings → MCP and click Reload (or restart Cursor).
- Distinctive: line 283
Run without arguments to serve MCP over stdio.
- Very specific: line 334
Every tool returns its payload inside the standard MCP content envelope ({ "content": [{ "type": "text", "text": "…" }], "isError": false …
- Very specific: line 365
Every path argument (path, path_a, path_b, vault, output_path and each cited_docs entry) may be absolute, relative to the server's working …
- Very specific: line 390
When a tool fails while running (the app is not running, a file cannot be read or written, the app rejects the operation), the call still …
- Very specific: line 567
file://<path>(vault files): every file the app lists in the - Very specific: line 687
Call request_review with that review_id to wait for the verdict.
- Very specific: line 736
When Ashlr MD is not running, get_user_annotations still answers, from disk: tasks are the checkboxes saved in the file, and verdict, …
- Very specific: line 764
Use request_review for gates: "Here's the plan / the diff / the final doc.
- Very specific: line 799
If the hook it installed earlier runs an mdopen that no longer exists (the app was moved, for example from Downloads to Applications), …
- Very specific: line 846
When the app is running, mdopen hands it the file over the token-authenticated loopback IPC channel, so it works even where the mdopener:// …
- Very specific: line 988
If the app is not running, most tools return a clear error.
- Very specific: line 1046
Every disk read (those tools, edit_canvas, file:// resources, the recent-documents resource and the local grep fallback) opens regular …
- Very specific: line 1137
Run claude mcp list to see the registered path, then verify the binary exists at that path and is executable (chmod +x).
- Very specific: line 1153
If ~/.claude/settings.json has a malformed hooks / hooks.PostToolUse shape, the installer bails rather than overwrite it.
≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Using Ashlr MD with AI Coding Agents
Ashlr MD ships an MCP server (mdopener-mcp) that lets AI coding agents
open, read, edit, search, export, and request human review of the document
that is live in your Ashlr MD window, without leaving their coding environment.
It also ships mdopen, a tiny CLI companion for opening any .md file from
a terminal or agent script, and for wiring up an auto-open hook that pops
Markdown into Ashlr MD the moment your agent writes it.
Tip: To watch everything an agent produces, open the Agent Activity drawer (
⌘Bon macOS,Ctrl+Bon Windows/Linux) and point it at the folder your agent writes to. New Markdown files appear live as they're created; click any one to open it instantly. It's the recommended way to keep an eye on an agent's.mdoutput without polling the filesystem yourself.
Copy-paste
Runnable commands, with no placeholders, are in EXAMPLES.md. The index for agents is llms.txt. The one-file manual is llms-full.txt. From the repository root:
SKIP_APP_BUILD=1 bash scripts/install.sh
mdopener-mcp doctor --json
mdopener-mcp setup --local --json
mdopener-mcp --ashlr-handshake --json
claude mcp add --scope user ashlr-md "$(command -v mdopener-mcp)"
codex mcp add ashlr-md -- "$(command -v mdopener-mcp)"
python3 scripts/connect-cursor.pyThe install script links mdopen and mdopener-mcp onto PATH (/usr/local/bin,
or ~/.local/bin when that directory is not writable). It registers Claude
Code when claude is already installed. doctor and setup --local print
JSON with --json, download nothing, and write nothing. Add --apply only
when you want doctor --fix to make the repairs
(doctor-fix.md).
What the integration is
There are two cooperating pieces:
mdopener-mcp: a Model Context Protocol server speaking JSON-RPC 2.0 over stdio. Agents (Claude Code, Codex, Cursor, anything MCP-capable) launch it as a child process and call its tools, read its resources, and use its prompts. It talks to the running Ashlr MD app over a token-authenticated loopback HTTP channel (see Security).mdopen: a CLI for opening files (mdopen file.md), reading stdin (cat x.md | mdopen -), and running as a Claude Code PostToolUse hook (mdopen --hook) so freshly written Markdown auto-opens for review.
The MCP server understands the MCP lifecycle (initialize →
notifications/initialized → tools/list / tools/call, plus ping,
resources/*, and prompts/*) and negotiates one of three protocol revisions:
2024-11-05, 2025-03-26, or 2025-06-18 (default). It echoes the
client's requested protocolVersion when it's one of these, and otherwise
advertises the latest, 2025-06-18. A JSON-RPC batch (an array of messages,
which 2025-03-26 clients may send) gets one array reply; a message without
an id is a notification and gets no reply. A notifications/* method
sent with an id is answered -32600, so the client is not left waiting.
A message whose jsonrpc is not "2.0", whose params is not an object, or
whose id is not a string or an integer (MCP never allows null) is answered
-32600 Invalid Request.
The initialize result carries instructions, which clients may give the
model: the tools to start with (open_file, present_document,
request_review, get_current_content, edit_document), that only
open_file starts the app, what to do when Ashlr MD is not installed (ask the
user to install it from the releases page), and the tools that work without
the app: export_text and export_metadata (with path), diff_documents
and edit_canvas, search_vault (with vault), get_user_annotations (task
checkboxes and saved review verdicts), list_ai_actions and the two
conversation tools. diff_documents takes path_a and path_b.
Quick start: one-click setup (in-app)
Open Ashlr MD → Preferences → AI agents (MCP) and click:
- Connect to Claude Code: runs
claude mcp addfor you, no terminal needed. - Connect to Cursor: writes/merges
~/.cursor/mcp.jsonautomatically. - Connect to Codex: runs
codex mcp addfor you. - Auto-open agent Markdown: installs a Claude Code
PostToolUsehook so written.mdfiles open in Ashlr MD automatically (see Auto-open hook).
The Connect buttons are only enabled for tools detected on your machine
(claude / codex on $PATH, or a ~/.cursor directory for Cursor). For a
tool that is not detected, a note under the buttons says how to install it;
detection runs again when you reopen Preferences. For any other agent, copy
the command with the Copy button and paste it in a terminal.
When Claude Code or Codex already has an ashlr-md server, the Connect button
asks it (mcp get) which program it runs. A registration for a program that no
longer exists (the app was moved or reinstalled) is removed and added again for
this app; one that runs another program that still exists is kept, and the
message names it and the command that removes it.
Claude Code
One-click (recommended)
Open Preferences → AI agents (MCP) and click Connect to Claude Code.
Ashlr MD runs the following for you (with <mcp-binary> resolved to the bundled
mdopener-mcp path):
claude mcp add --scope user ashlr-md <mcp-binary>The --scope user flag registers the server globally (all projects), not just
the current directory. Re-running Connect when ashlr-md is already registered
is safe: Ashlr MD asks Claude Code (claude mcp get ashlr-md) which program it
runs. A registration that already runs this app's server is left alone; one
whose program no longer exists (the app was moved or reinstalled) is removed and
added again with the current path; one that runs another program that still
exists is kept, and the message names that program and the
claude mcp remove --scope user ashlr-md command that removes it.
Manual
# macOS
claude mcp add --scope user ashlr-md "/Applications/Ashlr MD.app/Contents/Resources/mdopener-mcp"
# Windows (PowerShell): verify path; shown here for a typical per-user install
claude mcp add --scope user ashlr-md "$env:LOCALAPPDATA\Ashlr MD\mdopener-mcp.exe"
# Linux (.deb install default)
claude mcp add --scope user ashlr-md "/usr/lib/Ashlr MD/mdopener-mcp"
# Linux AppImage, without the app: copy the server out once, then register the copy
./AshlrMD.AppImage --appimage-extract "usr/lib/Ashlr MD/mdopener-mcp"
install -D "squashfs-root/usr/lib/Ashlr MD/mdopener-mcp" ~/.local/bin/mdopener-mcp
rm -rf squashfs-root
claude mcp add --scope user ashlr-md ~/.local/bin/mdopener-mcpAn AppImage mounts itself at a new /tmp/.mount_* directory on every launch
and unmounts when it quits, so a path inside the mount stops working once the
app closes. From an AppImage, the Connect buttons, the Copy command and
Auto-open agent Markdown therefore copy mdopener-mcp (and mdopen) to
~/.local/share/app.mdopener.desktop/bin/ and register that copy, which stays
put after the app quits. The copy is refreshed whenever you click one of them
after updating the AppImage. The AppImage commands above are only needed when
you register the server without opening the app; copy the server out again
after you update the AppImage.
After running it, restart Claude Code (quit and start a new session) once.
To get the exact command (with the real binary path) for your machine, use the Copy button in Preferences → AI agents (MCP); it produces precisely:
claude mcp add --scope user ashlr-md <resolved-mdopener-mcp-path>Verify
claude mcp list
# macOS: ashlr-md /Applications/Ashlr MD.app/Contents/Resources/mdopener-mcp
# Windows: ashlr-md C:\Users\<you>\AppData\Local\Ashlr MD\mdopener-mcp.exe
# Linux: ashlr-md "/usr/lib/Ashlr MD/mdopener-mcp"Using the tools in Claude Code
Once the server is registered, Claude Code can call the tools automatically when you're working on a Markdown file. You can also prompt it explicitly:
Open my plan.md in Ashlr MD and show me its current content.Edit the open document: change the heading "## Goals" to "## Objectives", then ask me to review it.Codex (OpenAI Codex CLI)
One-click (recommended)
Open Preferences → AI agents (MCP) and click Connect to Codex. Ashlr MD runs the following for you:
codex mcp add ashlr-md -- <mcp-binary>This writes an [mcp_servers.ashlr-md] table to ~/.codex/config.toml. Codex
consumes MCP tools (not resources/prompts), exactly what ashlr-md exposes
for agent control.
Manual: codex mcp add
# macOS
codex mcp add ashlr-md -- "/Applications/Ashlr MD.app/Contents/Resources/mdopener-mcp"
# Windows (adjust to your install dir)
codex mcp add ashlr-md -- "C:\Users\<you>\AppData\Local\Ashlr MD\mdopener-mcp.exe"
# Linux (.deb)
codex mcp add ashlr-md -- "/usr/lib/Ashlr MD/mdopener-mcp"Manual: config.toml
If you prefer editing config directly, the codex mcp add command above writes
the equivalent of this to ~/.codex/config.toml (global) or a project-level
.codex/config.toml:
[mcp_servers.ashlr-md]
# macOS:
command = "/Applications/Ashlr MD.app/Contents/Resources/mdopener-mcp"
# Windows (adjust to your install dir):
# command = "C:\\Users\\<you>\\AppData\\Local\\Ashlr MD\\mdopener-mcp.exe"
# Linux (.deb):
# command = "/usr/lib/Ashlr MD/mdopener-mcp"
args = []Codex picks up the new server on its next invocation (no restart needed).
Cursor
One-click (recommended)
Open Preferences → AI agents (MCP) and click Connect to Cursor. Ashlr MD
writes/merges the entry in ~/.cursor/mcp.json for you, leaving any other
servers untouched. A file it cannot merge (not valid JSON, for example with a
comment or a trailing comma, or whose mcpServers is not an object) is left
as it is and the button says why; fix it, or add the entry by hand as below.
Manual
Edit (or create) ~/.cursor/mcp.json (macOS/Linux) or
%USERPROFILE%\.cursor\mcp.json (Windows), the file Connect to Cursor writes:
{
"mcpServers": {
"ashlr-md": {
"command": "/Applications/Ashlr MD.app/Contents/Resources/mdopener-mcp",
"args": []
}
}
}On Windows, replace command with the path to mdopener-mcp.exe in your
install directory (e.g. C:\Users\<you>\AppData\Local\Ashlr MD\mdopener-mcp.exe).
On Linux, use "/usr/lib/Ashlr MD/mdopener-mcp" (.deb), or for an
AppImage the copy Connect to Cursor makes,
/home/<you>/.local/share/app.mdopener.desktop/bin/mdopener-mcp, or the one
made in the manual Claude Code steps above (a path inside the AppImage's
mount changes every launch).
If the file already has other servers, merge only the "ashlr-md" key into the
existing "mcpServers" object; don't replace the whole file.
After saving, go to Cursor Settings → MCP and click Reload (or restart
Cursor). You should then see ashlr-md listed with a green status indicator.
The mdopener-mcp CLI
Run without arguments to serve MCP over stdio. Informational commands exit
without starting the stdio server. Use these commands from the source checkout
with cargo run --manifest-path src-tauri/Cargo.toml -p mdopener-mcp -- <args>
if the sidecar has not been installed.
mdopener-mcp --version
mdopener-mcp --help
mdopener-mcp --export-tool-schema
mdopener-mcp --ashlr-handshake [--json]
mdopener-mcp doctor [--json]
mdopener-mcp doctor --fix [--apply] [--json]
mdopener-mcp setup --local [--json]Square brackets indicate optional arguments; omit the brackets when running a
command. --export-tool-schema prints the tool names and input schemas as
JSON, then exits. doctor checks local inference and offline status. doctor
--fix previews state repairs; add --apply to write them. setup --local
prints local model setup steps and downloads and writes nothing. Repair
boundaries: doctor-fix.md.
The offline schema export sorts tools by name and preserves complete input
schemas, including property descriptions, for companion-client compatibility.
tools/list keeps the presentation order shown in the tool table below.
The tool table below and the frontend event inventory are checked against
source by python3 scripts/check-docs.py.
Frontend MCP event handlers
The bridge in src/mcp/bridge.ts wires these listeners into the live window.
These are internal app events, not additional tools in tools/list.
The table is generated from the handler registrations by bun run doclive:sync.
<!-- doclive:begin mcp-events -->| Event | Handler module |
mcp://open | conversation.ts |
mcp://present | conversation.ts |
mcp://review | review.ts |
mcp://set-content | edit.ts |
mcp://edit | edit.ts |
mcp://stream-edit | edit.ts |
mcp://stream-edit-apply | edit.ts |
mcp://export | export.ts |
<!-- doclive:end -->MCP tool reference
The mdopener-mcp server exposes 20 tools over stdio JSON-RPC 2.0.
Every tool returns its payload inside the standard MCP content envelope
({ "content": [{ "type": "text", "text": "…" }], "isError": false }); the
"Returns" column below describes the JSON carried inside that text.
Rows are in tools/list order: the destructive tools are listed last,
edit_canvas (which rewrites a canvas file in place), export (which can
replace a file) and the two that replace the whole document,
replace_document and set_content, so reach for edit_document first.
| Tool | Parameters | Returns | Notes | |||||
open_file | path: string, mode?: "read"|"edit" | { opened, response } or { opened, method: "url-scheme", confirmed?, note? } | Opens a file in the Ashlr MD window. response is the running app's reply; method: "url-scheme" means the app was not reachable and the mdopener:// URL was handed to the OS launcher, with confirmed: false and a note when the launcher had not finished within 10 s. The only tool that launches the app if it isn't running (falls back to the mdopener:// URL scheme). Without mode the window keeps its current view (a file already open in a tab keeps its own, and an app this call launches starts in read); pass read before an export, which needs Read view. Only Markdown documents that are not dotfiles can be opened (see Security); another file is refused with "Refusing to open path '<absolute path>': only Markdown documents (…) that are not dotfiles can be opened." (present_document and request_review say "present" and "review"). A file that does not exist is refused (by the running app, or before launching it). A running app answers once its window has opened the file, so a file the window could not open (for example, one it cannot read) is a tool error with the reason, also when path is the document already open (a failed read of it used to be reported as opened, because that document was still the one open afterwards). A running app that answers with a server error or not in time is a tool error too, never retried through the URL scheme (which could open the file twice). An app that refuses this server's token (HTTP 401) gets the refused-token message (see Troubleshooting) and nothing is launched: the URL scheme would reach the same app. | |||||
get_current_content | none | { path, content } | Path + full Markdown source of the open document. path is the absolute path of the document's file: the app opens only files, so it answers path: null only when no document is open (its welcome screen, with empty content); this row used to say null meant an unsaved document, and the app has none. A path: null answer is no document whatever its content (it used to be refused only with empty content, here, in mdopener://current, the prompts and export_text / export_metadata, so a buffer with no file read as a document), as the edit tools treat it. With no document open the result is isError: true ("No document is open in Ashlr MD. Open one first (open_file)."), as mdopener://current (-32002) reports it. export_text and export_metadata without path are isError: true too, but say to pass path instead ("No document is open in Ashlr MD; pass path to read a file."). | |||||
list_recent | limit?: integer | [ { path, fileName, openedAt }, … ] | Recently opened files, most recent first (openedAt is milliseconds since the Unix epoch). limit defaults to 10; a value that is not a whole number from 1 to 200 is refused (-32602). Needs the app running (it keeps the list): it does not start it, so with the app closed it is an error, and open_file starts the app. | |||||
request_review | path?: string, content?: string, blocking?: boolean, timeout_ms?: integer, wait_ms?: integer, review_id?: string | { verdict, reviewId, comments } or { status: "pending", reviewId } | BLOCKING human sign-off. See Human review loop. One of path, content or review_id is required; an empty or whitespace-only content is refused with -32602 (it used to register a review of no text). verdict is approved, changes_requested, dismissed, timeout or superseded (a newer review replaced it in the panel). Needs the app running: it does not start it, so call open_file first when the app is closed. | |||||
get_user_annotations | path: string | { path, verdict, comments, late, tasks, tasksNote?, appRunning?, appAuthFailed?, note? } | The human's latest review verdict + comments for that file (verdict and comments are null when no review of it was requested), plus its GFM task-checkbox states, read where the page shows a checkbox (> - [ ] t and 1. [ ] t are tasks; a - [ ] line in fenced or indented code or front matter is not, and - [x] with nothing after it is text): from the live buffer when it is the open document, else from the Markdown file on disk (none for other files). A file on disk larger than 16 MiB (or not UTF-8 text) is not read: tasks is empty and tasksNote says why, then "Its tasks were not read." (that sentence used to run on from a reason with no full stop). Works without the app: the tasks are read from the file on disk, verdict and comments come from the review the app saved in ~/.mdopener/reviews/ (null when none; stale: true when the file changed since), and appRunning: false with a note says so. An app that answers HTTP 401 (a stale ~/.mdopener/ipc-token, or another instance on the port) gets the same disk read, with appAuthFailed: true and a note naming the token and saying to quit and restart Ashlr MD. Read-only. | |||||
edit_document | find: string, replace: string, save?: boolean, path?: string | { ok: true, replaced: 1 } | Exact, unique find/replace on the open document. See below. With no document open it returns isError: true ("No document is open in Ashlr MD; open one first (open_file).", as set_content says) and nothing is applied (it used to say that find was not found). A find that is not found or not unique, or a path that is not the open document, returns isError: true with the reason and leaves the document unchanged; a find that is not found (here and in stream_edit and stream_edit_apply) says it must match exactly, whitespace included (a line break matches LF or CRLF), and to copy it from get_current_content (it used to stop at "not found"). As for set_content, at most two edits wait on the app at once. A window that does not answer within 4 s returns isError: true too, but it may still apply the edit, so the error says to call get_current_content to see whether it did before retrying (this row used to count it among the refusals that leave the document unchanged). With save: true, a save that fails returns isError: true saying the edit was applied but not saved; stream_edit_apply does the same. The app opens only files (with none it shows its welcome screen), so every document it shows has one: both descriptions used to promise an "applied but not saved" result for "a document with no file yet", and the window applied an edit to a buffer with no file that was not empty, out of sight; it now refuses any state with no file as "No document is open", as set_content does. | |||||
search_vault | query: string, limit?: integer, semantic?: boolean, vault?: string | { results, mode, query?, note?, vault?, truncated?, depthCut? } | Search across the vault (watched folder) + recents. query comes with mode grep only: the query as the app's own search read it. The app lists the watched folder's 200 most recently changed Markdown files (the app sends that cap with its vault list, and the note quotes the app's number); when the folder has more, the result has truncated: true and a note saying only those were searched (the Activity drawer says so too). The app keeps the newest files of the whole folder while it walks it, but reads at most 50,000 directory entries and enters folders at most 8 levels down; past the entry budget the walk stops, and below that depth a folder is skipped. Either way the list is the newest of the files it reached, and truncated: true comes with a note saying newer files elsewhere in the folder may have been missed. When the depth limit is what cut the walk, the result also has depthCut: true and the note says the folder is too deeply nested (with only the entry budget, there is no depthCut and the note says the folder is too large); the app's /vault reply carries the same scanCut (either limit) and depthCut (the depth limit) flags. An older app that sends no depthCut gets the note naming both limits. Semantic (Ollama nomic-embed-text embeddings) when Ollama is reachable on 127.0.0.1:11434, otherwise case-insensitive grep; semantic forces one or the other. mode is semantic, grep (the app's own search), or grep-local (the app's search failed, so the vault files were grepped on disk). When semantic search was tried but unavailable (for example semantic: true with Ollama down), or the app's search refused or failed, note says so in words and quotes the reason. When only some documents fail to embed, the semantic results leave them out and note says how many. In every mode, a vault or recent Markdown file that cannot be read (not UTF-8 text, larger than 16 MiB, or a read error) is not searched, and note says so (in mode grep from the app's /search reply, which lists such files as unread: [{ path, reason }], the first 20, with unreadCount for all; an app that predates those fields gives no such note): how many files, the first three by path with the reason ("1 file was not searched because it could not be read: /v/latin.md (not UTF-8 text (byte 4 is not valid UTF-8, on line 1)). Convert a file that is not UTF-8 to UTF-8 to make it searchable."), and how many more; such files used to be left out in silence. (Mode semantic embeds the files itself; the app's own semantic index, which grounds its "My library" chat, is separate, and Settings > Semantic search names the files a reindex could not read.) if the app refused and the on-disk grep cannot run either, the refusal is the error. limit defaults to 50 and is a whole number from 1 to 200 in every mode; a value outside that range is refused with -32602, by the tool and by text://vault-search?…&limit=, as for list_recent (a value above 200 used to be read as 200). Needs the app (it supplies the vault file list). When the app cannot be reached (no ~/.mdopener/ipc-port, or nothing listening) or refuses this server's token (HTTP 401), but not when it refuses the request, vault names a directory (absolute, relative to the server's working directory, or under ~/) whose Markdown files are searched on disk instead: mode is grep-disk (or semantic), vault is the directory searched, and note says the app was unreachable and the disk was searched. After a 401 the note (here and in mode grep-local) gives the cause and the fix of the refused-token message (see Troubleshooting) instead of quoting the raw "IPC auth failed" text. The walk reads only Markdown files that are not dotfiles, skips hidden folders, node_modules and target, does not follow directory symlinks, and stops at 5000 files (the note says so). A file symlink is followed: the file it resolves to is searched, and listed by that resolved path, when it is a Markdown file that is not a dotfile, even if it is outside vault (a link's own name is judged too, so a link named .notes.md is skipped). It enters folders at most 16 levels below vault; a deeper folder is not searched, and the result then has truncated: true, depthCut: true and a note saying so (deeper folders used to be skipped in silence). Without vault, an unreachable app or a refused token is isError: true, and the message names vault (after a 401 it is the refused-token message followed by "To search without the app, pass vault: the directory of Markdown files to grep on disk."; it used to be the refused-token message alone). A vault that is not a directory, or is itself a hidden folder (.git, .obsidian), is isError: true. | |||||
present_document | path?: string | { ok, path } | Open a file (if path given) and switch the app into a distraction-free reading presentation (Read view in Zen mode: the title bar, tabs, status bar and side panels are hidden, the window keeps its size, and Escape leaves it; this row and the tool description used to call it full-screen, but the window is not made full-screen), ideal for showing a finished result. Without path it presents the open document, and is an error when none is open that names the next step ("No document is open to present. Pass path to present_document, or call open_file with the document's path first."; the window used to answer only "No document is open to present."). A path that does not exist is refused before the app is asked, so the error says so even when the app is not running. It answers once the window has opened and presented the document, and a failure there is a tool error with the reason. Needs the app running: it does not start it, so call open_file first when the app is closed. | |||||
list_ai_actions | include_prompts?: boolean | { actions, count } | The editor's AI transform actions (explain, summarize, rewrite, …) with id, label, shortLabel, icon, scope, and (by default) their prompt templates (systemPrompt, userPromptTemplate); include_prompts: false leaves the templates out. Templates read the text as {text}: the selection, or for scope document the document, of which the app's AI sidebar sends at most the first 8,000 characters (doc-explain-selection keeps its id but explains the document, as the sidebar has always sent it; its label and prompt used to say it explained a selection); translate and explain-diff also take {arg}, and carry an argument field saying what it is (the target language, or the on-disk version). No app needed. | |||||
get_conversation_context | session_id: string, limit?: integer | { sessionId, messages, count, recentDocs } | Each message is { id, sessionId, role, content, citedDocs, timestampMs } (timestampMs in milliseconds since the Unix epoch). The last N messages (default 20, a whole number from 1 to 200; a value outside that range is refused with -32602, as for list_recent, where one above 200 used to be clamped) of a session's memory store, oldest first. recentDocs lists the paths those messages cite, most recently cited first and each once (it used to be in random order). An empty or blank path that an earlier build stored is left out of recentDocs and of each message's own citedDocs. A path an earlier build stored twice in one message is listed once in that message's citedDocs. Empty when the session has none. A session file larger than 16 MiB is not read: the result is isError: true saying how large it is (it used to be read whole, and an unreadable file answered as an empty session). A session_id too long for its file name is refused with -32602 (see save_conversation_message). | |||||
save_conversation_message | session_id: string, role: "agent"|"human"|"system", content: string, cited_docs?: string[] | { ok, messageId, sessionId, role, timestampMs, dropped?, note? } | Append to a session's memory store, persisted at ~/.mdopener/sessions/<session_id>.json. ASCII letters, digits, - and _ are kept in the file name and every other byte is written as %XX, so distinct ids never share a file (a.b is a%2Eb.json, a_b is a_b.json). That file name (without .json) may be at most 200 bytes, so an id of up to 200 letters, digits, - and _ always fits (other bytes count three each); a longer id is refused with -32602 instead of failing when the file is written. The sessions directory is owner-only (0700, tightened if an earlier build left it open) and each session file is 0600, on Unix. Where a case-insensitive file system still folds two ids together, reads skip the other id's messages and appends are refused. History saved by earlier builds under the old name (every other character replaced by _) is still read, keeping only that id's messages, and the next append moves it to the new file. The Ashlr MD window persists its side of a session into the same file, in the same format (each message's citedDocs once per path, in the order first given, as this tool stores them), and both hold ~/.mdopener/sessions/.lock while they update a file, so neither overwrites the other's messages. A file that cannot be parsed, or is larger than 16 MiB, is left as it is and the append is refused. A session file is kept within 16 MiB: when a message would take it past that, the oldest messages are dropped and the result says how many (dropped, with a note). A message that cannot be written to disk is not saved and returns isError: true. An empty or blank (whitespace-only) path in cited_docs is refused with -32602 (it used to be stored and come back in recentDocs). Each cited_docs path is stored absolute: a relative one is resolved against the server's working directory when saved (it used to be stored as given), and one starting with ~/ against the home directory, and an absolute one is kept as given. A path given twice (or a relative path and its absolute form) is stored once, in the order first given (it used to be stored and returned twice). | |||||
diff_documents | path_a: string, path_b: string, context_lines?: integer | { diff, hunks, added, removed, path_a, path_b } | Unified diff of two Markdown files on disk (non-Markdown files and dotfiles are refused). hunks counts the change hunks and added / removed the lines added and removed; path_a / path_b are the absolute paths read, and identical files give an empty diff and three zero counts. context_lines defaults to 3 and is a whole number from 0 to 10000; a larger value is refused with -32602 (it used to be read as 10000). Lines are compared with their line endings, so files that differ only in CRLF vs LF or in the final newline are not reported as identical: a \r stays visible in the diff, and a last line without a newline is followed by \ No newline at end of file, as in diff -u. | |||||
stream_edit | find: string, replace: string, preview?: boolean, preview_lines?: integer, path?: string | { ok, editId, diff, candidates, matchCount, applied } | Preview a find/replace on the open document as a unified diff (every candidate when find matches more than once). diff is the diff of the first (or only) match, candidates is null for a single match and otherwise [{ matchIndex, startLine, diff, rank }] in document order (rank 1 first), and matchCount counts the occurrences. With no document open it returns isError: true ("No document is open in Ashlr MD; open one first (open_file).", as edit_document says; it used to say that find was not found). A find that is not found (or, with preview: false, not unique, which the error counts: "not unique (3 matches)") or a path that is not the open document returns isError: true with the reason, and the document is unchanged; so does a preview's window that does not answer within 4 s. With preview: false a window that does not answer within 4 s returns isError: true too, but it may still apply the edit, so the error says to call get_current_content to see whether it did before retrying (this row used to say the document was unchanged). A refusal from the window's search also carries matchCount beside content and isError (the text and the rest of the result are unchanged): the occurrences of find it counted, 0 when it is not found. The app's /stream-edit reply used to drop it ({ ok: false, error }), and it now sends matchCount with them; an older app sends none, and the result then has no matchCount. The other refusals (no document open, a path that is not the open document, a busy app, a window that does not answer or fails while it handles the edit) did not search, so they carry none; the window used to send a made-up 0 for them. preview defaults to true; false applies immediately (not written to disk, find must match once) and the result has applied: true, an empty diff, candidates: null and an editId that names no preview, so do not call stream_edit_apply after it (a preview has applied: false; the row used to leave the empty diff unsaid). preview_lines defaults to 5 and is a whole number from 0 to 20; a larger value is refused with -32602 (it used to be read as 20). A path must name the open document (compared after resolving symlinks, as edit_document does). Like edit_document, at most two edits wait on the app at once; another returns a "busy, retry" error. Prefer it over edit_document only to see the change before committing it: it is no cheaper for a large file, since both send the app only find and replace, a preview adds a diff and a second call, and with path it also asks the app for the open document's path, not its content, to compare (its description used to say to prefer it for large files, and this row that path fetched the whole document, which it no longer does). | |||||
stream_edit_apply | edit_id?: string, find: string, replace: string, match_index?: integer, save?: boolean, path?: string | { ok: true, replaced: 1, matchIndex } | Apply a previewed stream_edit, choosing the occurrence with match_index (0-based, default 0); matchIndex in the result is the occurrence applied. With no document open it returns isError: true ("No document is open in Ashlr MD; open one first (open_file).", as edit_document says) and nothing is applied or saved. A find no longer in the live document, a match_index past its last occurrence ("matchIndex 3 out of bounds: document has 2 occurrence(s). Use a match_index from 0 to 1, or call stream_edit again to see the candidates."), or a path that is not the open document returns isError: true with the reason and leaves the document unchanged, as edit_document does, as does a third edit while two wait on the app. A window that does not answer within 4 s returns isError: true too, but it may still apply the edit, so the error says to call get_current_content to see whether it did before retrying (this row used to count it among the refusals that leave the document unchanged). match_index is a whole number from 0 to 4294967295, the app's 32-bit limit, which the schema declares as its maximum; a larger value is refused with -32602 (`match_index` must be at most 4294967295, got …; it used to be refused only as "too large", with no bound in the schema). The app re-finds find in the live document. An empty or whitespace-only edit_id is refused with -32602 (it used to be accepted). A given edit_id must name a preview the running app made of the open document, with the same find and replace, that has not been applied yet; a preview applies once. Otherwise the call returns isError: true and the document is unchanged: an unknown one (already applied, made before the app last started, or never made) says "Unknown or stale editId '<id>': ...", a preview of another document says "Stale editId '<id>': it previews '<path>', but the open document is now '<path>'.", and both end "Call stream_edit again for a fresh preview, then stream_edit_apply with its editId." (it used to be forwarded unchecked, so a stale preview re-found find in whatever document was open). A refusal because find is no longer in the live document or match_index is past its last occurrence also carries matchCount beside content and isError (the text and the rest of the result are unchanged): the occurrences of find the window counted. The other refusals (no document open, a path that is not the open document, a stale edit_id, a busy app, a window that does not answer) did not search, so they carry none, and neither does an older app; the app's /stream-edit-apply reply used to carry none at all. Without edit_id the apply is not checked against a preview. With the app closed the error says to start it (with open_file), then call stream_edit again for a fresh preview, since a preview from before the app started no longer exists. | |||||
export_text | path?: string | { text, word_count, char_count, path } | Plain text of a Markdown file with the syntax stripped (code blocks kept as written, by the fence rule of export_metadata; code in a blockquote or a list item loses the > markers and the item's indentation it sits in, where list-item code used to keep its indentation; a ~~~ line inside a backtick block used to end it, and the code after it lost its # and * marks; indented code (four columns past its blockquote markers or list item text, not continuing a paragraph) is kept as code without them and its indent, where an indented line after a list item used to be read as text until the list ended, so code in an item lost its # and * marks; an item's line indented less is its continuation; and a heading loses its #s only by the ATX rule of export_metadata, so a # comment indented four spaces used to lose its #; blockquote > leaders are taken off before that rule, so > # Quote reads Quote, where it used to keep its #; each line is read past the blockquote markers, list markers and item indentation it sits in, by the container rule of the fences, so - # Step reads Step and 1) items lose their markers, where a list item's heading used to keep its #): a setext heading underline (=== or --- after a paragraph) reads nothing, but an === line the page shows as text (after link reference definitions, with no paragraph before it, or lazily after a list item) is kept, where every line of only = or - used to be dropped, ~~struck~~ text keeps its words, table rows become tab-separated cells, also in a blockquote or list item (the ` | --- | row is dropped,|reads | ; as in GFM and the app's own rendering, an unescaped | inside ``code `` still splits a cell), inline Markdown is read per paragraph, so a code span, inline math, emphasis or a link may run over a paragraph's line breaks (it used to be read one line at a time, so a code span over two lines kept its backticks), inline math ($x$, $$x$$, as the page's remark-math reads it) keeps its TeX without its $s, where its *and_used to be taken off as emphasis, and a link or image in it is none, also forexport_metadata, footnotes read [1]at the reference and[1] text at the definition, a link reads its text and an image its alt text, without their inline marks (areadsa, where it used to keep its **), when its (...) is a CommonMark destination and title ([t](a b)is no link and reads as written, where it used to readt; a link holds no link and its text no wikilink, so [[W]]reads[[W]], where it used to read W), but a [text]with no(url) after it keeps its brackets unless the document defines its label ([text]: /url), as on the page (any [text]used to lose them, so[sic]readsic), a link reference definition ([text]: /url "title", also over two or three lines) reads nothing, as on the page, where it used to read text: /url "title", an autolink reads its URL or address (https://x, me@x.org) and inline HTML reads nothing, by the CommonMark rules (an HTML tag used to be any <and a letter up to the next>, so me@x.orgread nothing and<a href="x://y">reada href="x://y"), an HTML block (a <div>or a lone<u>line up to a blank line, a comment up to its-->) reads its text without its tags and is not read as Markdown, as the page shows it as raw HTML (a # xin a<div>used to lose its#, and the ===after a<u> line was dropped as a setext underline), a footnote definition ([^1]: text) holds the lines indented four columns after it, as remark-gfm reads it, so a heading, a fence or a link reference definition in it is read as one, and [^a b]: x(no footnote, as its label has a space) is a link reference definition, a<...> link destination is read whole (treadst), and a wikilink reads its target, or its alias when it has one ([[Plan | the plan]]readsthe plan; [[Real]]used to read[Real], and a ![[a.png]]embed[a.png]; an embed now reads nothing). The text has no blank lines before its first line or after its last, where it used to start with a line break after leading blank lines, front matter or a definition. char_countis the text's length in Unicode characters, not bytes. Readspath(Markdown only, no dotfiles) from disk, or the open document via the app when omitted (an error when nothing is open, or when the app cannot be reached; that error says to passpath, which needs no app). Without path` it reads the document as the window holds it, edits not yet saved included, not its file on disk; the description used to say it operates on disk and returns the path of "the file read". |
export_metadata | path?: string | { frontmatter, headings, wikilinks, wikilink_headings, embedded_image_count, word_count, path } | Structural metadata of a Markdown file: headings is [{ level, text }], a flat list in document order, for every ATX heading outside a leading YAML front matter block and fenced code (an empty one, # alone, is left out; ``` or ~~~, closed only by the same character at least as long; an open fence runs to the end; a front matter # comment and a # line after a ~~~ inside a backtick block used to be headings; a heading has at most three spaces before its #s, so a # comment indented four spaces is indented code, which used to be a heading; a heading inside a blockquote, > # Quote, is not listed, as in Obsidian's outline and the app's Outline panel, nor one in a list item, - # Step or one on an item's continuation line, - x, a blank line, then # Sub, which used to be listed), wikilinks the distinct [[link]] targets sorted, outside fenced code by the same fence rule, indented code and code spans (a [[link]] in indented code or in `code` used to be listed; a front matter property such as related: "[[Plan]]" is a link, as Obsidian reads it; a ~~~ line inside a backtick block used to make the code after it links; a link is one line, as in Obsidian, so an unclosed [[ no longer pairs with a ]] lines later and hides the links between them; a target is read as the app reads it: the note without its #heading, so [[Plan#Goals]] links to Plan, where it used to list Plan#Goals, and a ] ends no target, so [[a]b]] is not a link, where it used to list a]b; [[#Goals]], a heading of the same note, has no target and is not listed), wikilink_headings the distinct { target, heading } of each [[target#heading]] link, sorted by target then heading (a field added beside wikilinks, which keeps its shape), embedded_image_count the  and ![[...]] images outside fenced and indented code, code spans and front matter (an image is one only when its (...) is a CommonMark destination and title, so  is not; a property is not rendered, so an image in one embeds nothing; a ![[ embed is one line too, so an unclosed one is not counted), and path the document's absolute path (with path omitted, the open document's file, whose text is read from the window, edits not yet saved included, as in export_text). Reads path (Markdown only, no dotfiles) from disk, or the open document via the app when omitted (an error when nothing is open, or when the app cannot be reached; that error says to pass path, which needs no app). frontmatter is null when a leading --- is never closed by --- or ... (that line is a thematic break, and export_text keeps the text after it). The supported YAML subset is described under Front matter in export_metadata. A leading UTF-8 byte order mark (as Notepad saves) is ignored by both export_text and export_metadata, so it does not hide the front-matter. | |||||
edit_canvas | path: string, ops: object[], save?: boolean | { ok, path, nodes_count, edges_count, node_ids, edge_ids, removed_edge_ids, ops_applied, saved, content } | Apply typed edit ops (move/resize/edit/color/add/delete nodes and edges, reorder edges) to a .canvas file, all or nothing. The extension matches in any case (Board.CANVAS), as Markdown extensions do (only a lowercase .canvas used to be taken), and a path that does not end in it is refused with -32602 naming it (`path` must point to a .canvas file, got `notes.md`). ops must hold at least one op: an empty list is refused with -32602 (`ops` must not be empty) before the file is read, so it is never written (the schema declares minItems: 1). node_ids / edge_ids are the ids left afterwards (for follow-up ops), removed_edge_ids the edges delete_node removed with their node, and content the new canvas JSON (a string) when save is false, which writes nothing, and null when the file was written. save defaults to true. Top-level keys other than nodes and edges, and unknown keys on nodes and edges, are written back unchanged; a canvas whose nodes or edges is not an array of objects is refused with isError: true and left untouched. Op fields are checked like arguments, before the file is read: a field of the wrong type, a field no op takes, an op without type, or a field shorter than its declared minLength (an op's empty id or ids element) is refused with -32602 naming the op and field (`ops[1].x` must be a number, `ops[0].id` must be at least 1 character long). So is a field that another op takes but this op does not use (width on a move_node, which used to be ignored while the call reported success): the error names the ops that use it, and a move and a resize are two ops. An empty or whitespace-only id (an op's id, an element of reorder_edges' ids, add_node's node.id, or add_edge's edge.id, edge.fromNode or edge.toNode) is refused with -32602 before the file is read (op[0] move_node: `id` must not be empty; it used to come back as "node not found"). `move_node` and `resize_node` write a whole-number `x`, `y`, `width` or `height` as a JSON integer, as JSON Canvas expects (`5` or `5.0` is written `5`; it used to be written `5.0`), and a fractional one as given. `add_node` refuses (`-32602`) a node the app would not show: it needs a non-empty string `id`, a `type` of `text`, `file`, `link` or `group`, numbers `x`, `y`, `width` and `height` (size positive), a string `file` for a file node and `url` for a link node. `add_edge` likewise refuses (`-32602`) an edge whose `fromSide`/`toSide` is not `top`, `right`, `bottom` or `left`, whose `fromEnd`/`toEnd` is not `none` or `arrow`, or whose `label` is not a string (these used to be written as given). A color (`set_node_color`'s `color`, `add_node`'s `node.color`, `add_edge`'s `edge.color`) must be what JSON Canvas allows, a preset `"1"`..`"6"` or a hex `#rgb` / `#rrggbb`; anything else (`"red"`, `"7"`) is refused with `-32602`. A node or edge id that is not in the canvas (in `move_node`, `delete_edge`, `add_edge`'s `fromNode`/`toNode`, `reorder_edges` and the rest) is refused with `-32602` listing the ids that exist at that op, after the ops before it (the first 20, then a count), and saying the file was left unchanged ( op[0] move_node: node n2 not found. The canvas's node ids at this op are n1, n3. No op was applied and the file was left unchanged. ``); it used to name only the missing id. It is listed with destructiveHint: true, among the destructive tools after the read-only and targeted ones: it writes the canvas back in place (save defaults to true) and can remove nodes and edges, and it used to say false, so a client that confirms destructive tools let it rewrite a canvas unasked. | |||||
export | format: "pdf"|"docx"|"html"|"epub", output_path?: string, overwrite?: boolean | { ok, format, path, bytes } or { ok, format, dialog: true } | Export the open document. With none open it is isError naming the next step ("No document is open to export. Call open_file with the document's path and mode "read", then retry."; it used to say only "open one before exporting"). With output_path (html, docx or epub, with that extension, in an existing directory) the app writes the file without a dialog and the call returns once the window reports it written and it is on disk. An export that fails in the window returns isError at once with the window's reason; from Edit or Source view that reason says to call open_file with the document's path and mode: "read", then retry (it used to be "Switch to Read view before exporting.", a toggle only the human can click); one the window has not finished within 30 s returns isError saying so. The app writes one headless export at a time, in a slot of its own, so a slow export never makes edits, opens, presents or reviews wait; a second headless export sent while one is being written returns isError at once saying so (retry once the first finishes). The document must be in Read view. Without output_path the app opens its export dialog. pdf goes through the system print dialog and takes no output_path. Exports use the app's current theme; a theme argument is refused (-32602). A file already at output_path is replaced only with overwrite: true (default false): without it the call returns isError saying it exists, and nothing is written or sent to the app. A file that appears after that check is not replaced either: the app is told not to replace one, and writes the export to a temp file published with a hard link (or created with O_EXCL where links are not supported), which fails when anything is at the path, so the call returns isError saying a file is already there. The check and the write used to be two steps. It is listed with destructiveHint: true, after the read-only and targeted tools; it used to say false and overwrote the file unasked. | |||||
replace_document | content: string, save?: boolean | { ok: true } | Replace the entire content of the open document, the same operation as set_content. Prefer edit_document for targeted changes. In a CRLF document (one with \r\n and no lone \n) a content with no \r takes CRLF line breaks, and a document that starts with a byte order mark keeps it when content has none. Refuses with no document open and reports a failed apply or save as set_content does. | |||||
set_content | content: string, save?: boolean | { ok: true } | Replace the entire content of the open document, the same operation as replace_document, and like it listed with destructiveHint: true. save defaults to false. The call returns { ok: true } once the window has applied the content (and saved it, with save: true). With no document open it returns isError: true ("No document is open in Ashlr MD; open one first (open_file).") and nothing is applied. The app opens only files (with none it shows its welcome screen), so every document it shows has one, and there is no unsaved document to fill: the description used to promise a "replaced but not saved" result for "a document with no file yet", which never came. With save: true, a save that fails returns isError: true saying the content was replaced but not saved (the buffer keeps the new content), and a window that does not answer within 4 s returns isError: true saying it may still apply the content, and to call get_current_content to see whether it did before retrying. Like edit_document, at most two edits wait on the app at once; another returns a "busy, retry" error. A content larger than the 16 MiB the app reads in one request (counted with JSON escapes) is refused with -32602 before it is sent, as for replace_document, edit_document, stream_edit, stream_edit_apply and request_review (see the paragraphs below this table). |
Every path argument (path, path_a, path_b, vault, output_path and
each cited_docs entry) may be absolute, relative to the server's working
directory, or start with ~/ (or be ~ alone) for the home directory, as in
a shell. A leading ~ used to be read as a folder named ~ in the working
directory, so ~/notes/plan.md came back as a file that does not exist.
~user and a ~ later in the path are kept as given.
A document a tool reads from disk must be UTF-8 text. One that is not (a
Latin-1 or UTF-16 file, as some Windows editors save) is isError: true
naming the first byte that is not valid UTF-8 and its line (or saying it is
UTF-16), and saying to convert the file to UTF-8; it used to say only "stream
did not contain valid UTF-8". The app gives the same reason when it reads
the file itself, so open_file, present_document and request_review (with
path) of such a file fail with it too, and so does the window's own open
and a patch the human applies to the file.
The app reads at most 16 MiB of one request. A call that would send it more
(set_content or replace_document with a larger content, edit_document,
stream_edit or stream_edit_apply with a larger find and replace, or
request_review with a larger inline content), measured as sent, with JSON
escapes, is refused with -32602 before anything is sent, saying what to do
instead: change part of the document with edit_document, split the edit,
or pass the review text as a file's path. It used to be sent, and the app's
refusal reached the agent as "IPC write error: Connection reset by peer".
When a tool fails while running (the app is not running, a file cannot be read
or written, the app rejects the operation), the call still succeeds at the
JSON-RPC level and returns isError: true with the reason as text, as the MCP
spec asks, so the agent sees the message and can act on it. JSON-RPC errors
are reserved for protocol problems: an unknown tool or invalid arguments
(-32602).
Arguments are checked against the tool's inputSchema before it runs. An
argument the schema does not declare (a typo such as pth), a value of the
wrong JSON type ("limit": "5", "include_prompts": "no", a number inside
cited_docs) or arguments that is not an object is refused with -32602
naming the argument; an undeclared one also lists the arguments the tool
takes and the closest match. Every integer argument declares a minimum in
its schema (0, or 1 for the limit of search_vault, list_recent and
get_conversation_context, and for request_review the 5000 of
timeout_ms and the 1000 of wait_ms), and a value
below it ("limit": -5, "match_index": -1) is refused the same way instead
of falling back to the default. An integer above its declared maximum
(the limit of search_vault, list_recent and get_conversation_context declares 200, diff_documents' context_lines 10000, stream_edit's preview_lines 20, stream_edit_apply's match_index 4294967295, and request_review's
timeout_ms and wait_ms 600000) is refused the same way. A string outside the property's enum
("mode": "view", "role": "bot", an edit_canvas op type of
"move") is refused the same way, and the message lists the allowed values
(`mode` must be one of `read`, `edit`, got a string ("view")). A string
shorter than the property's minLength is refused the same way. Every
required string a tool cannot use when empty declares 1: search_vault's
query, edit_document's and stream_edit's and stream_edit_apply's
find, get_user_annotations's and edit_canvas's path, and the
session_id of both conversation tools and save_conversation_message's
content. So does every path a tool reads or opens (open_file's,
present_document's, export_text's, export_metadata's and
request_review's path, diff_documents' path_a and path_b),
request_review's inline content and search_vault's vault, and
the optional path of edit_document, stream_edit and
stream_edit_apply, stream_edit_apply's edit_id and export's
output_path (omitting them is still allowed): an empty one, or one of only
whitespace, is refused with -32602
(`path` must not be empty), where it used to reach the file checks and
come back as "only Markdown documents can be read", "No such file or
directory", "not the open document" or "cannot make an empty path
absolute". search_vault also refuses a query that is
only whitespace with -32602, before it asks the app (it used to report that
the app was not running). So a string such as "1", a negative or a
fractional match_index is refused rather than read as 0, and "save":
"true" rather than read as false. A whole number written as a float
(2.0) is an integer, as in JSON Schema. null counts as not given. A
required argument that is missing (or null) is refused the same way
(`ops` is required) before the tool reads anything, and request_review
without any of path, content or review_id is refused naming all three.
A test drives every tool's advertised schema through this check, so the
schema's required list, property types and enum values are what is enforced. An unknown tool
name gets the closest tool name, if any, and a pointer to tools/list. A
blank session_id is refused like a missing one. prompts/get with an
unknown or missing name and resources/read with an unknown or missing
uri answer -32602 the same way: the closest prompt or URI, if any, and
the prompt names or a pointer to resources/list.
edit_document: exact, unique find/replace
edit_document replaces an exact substring of the currently open document.
The find string must occur exactly once:
- 0 matches → the edit is refused: "
findstring not found in the current document." - 2+ matches → the edit is refused: "
findstring is not unique (N matches): include more surrounding context to disambiguate." - empty
find→ refused by the MCP server itself with-32602("findmust not be empty"), whether or not the app is running.
Matching is exact except for line breaks: a line break in find (\n or
\r\n) matches either in the document, so an LF find edits a CRLF file
(it used to be "not found" there), and replace is written in the line breaks
of the line the match starts on, so a CRLF file keeps CRLF lines and a mixed
file keeps each part's. The same holds for stream_edit and
stream_edit_apply. A byte order mark and the text around the match are left
as they are.
So include enough surrounding context to make find unique. These soft failures
come back as a tool error (isError: true) with the reason, not an opaque
transport failure, so the agent can retry with more context.
Pass path to assert which file is open: if a different document is currently
open, the edit is refused rather than silently applied to the wrong file. Pass
save: true to persist to disk immediately (default false: edits stay in the
live buffer until saved).
request_review: blocking human sign-off
See the dedicated Human review loop section.
Front matter in export_metadata
The block opens on a first line of --- and closes on a later --- or
... line. Each delimiter starts at column 0 with only spaces or tabs after
it, as the app's renderer reads front matter, so --- is a thematic break
and opens no block (the line used to be trimmed, which hid the text up to the
next --- from the headings, tasks and export_text).
frontmatter reads a small subset of YAML, not full YAML:
- Scalars:
true/falsebecome booleans, integers and decimals become numbers,~,nulland an empty value (key:with nothing after it and nothing under it) becomenull, and anything else is a string with surrounding"or'removed (key: ""is the empty string). - Lists: a flow list (
tags: [a, "b, c"]) or- itemlines under an emptykey:become arrays. An item written as- name: xstarts an object, and the lines aligned withnameadd keys to it, so- name: Ann/role: leadis{"name": "Ann", "role": "lead"}. An item in quotes, or whose colons are not followed by a space (- http://x), stays a string. - Nesting: indented
name: valuelines under an emptyowner:become an object ({"owner": {"name": "value"}}), at any depth. Their values are read the same way, solinks: [a, b]or- itemlines underroles:are arrays, and an empty one isnull. - Block scalars:
key: |keeps the indented lines under it as written, andkey: >folds them: a line break between two lines becomes a space, a blank line becomes a line break, and lines indented further keep their breaks. Both end with one line break;|-/>-drop it and|+/>+keep every trailing blank line. An explicit indentation digit (|2) is honoured. Lines inside a block scalar are text, so# ...there is kept. - A key's colon must be followed by a space or end the line, so
time: 10:30is the string"10:30". - Not parsed: anchors and aliases (
&a,*a), tags (!!str), flow mappings ({a: 1}stays a string), and multi-line plain or quoted scalars (their continuation lines are skipped). Comment lines (# ...) outside block scalars are skipped.
Resources reference
The server implements resources/list, resources/templates/list and
resources/read. resources/templates/list offers one template,
text://vault-search?q={query} (mimeType: application/json), the search
below. Listing it needs no app; reading it needs the app unless &vault= is
given (this used to say only "it needs no app"). The resources/list entry
names the same parameters, &limit= included (it used to leave limit out).
mdopener://current: the document currently open in Ashlr MD (mimeType: text/markdown). Reading it returns the live content; an open document that is empty is"". With no document open (the app answerspath: null, whatever itscontent) it is-32002("No document is open"), as for the prompts. It is-32000when the app is not running, and also when the app answers HTTP 401 (it refuses this server's token), with the refused-token message (see Troubleshooting) instead of the not-running one. Any other HTTP error the app answers (a 404 or a 500) is-32000too, with the app's reason as the message (a 500 whose reason said "not found" used to read as a closed app).text://vault-search?q=<query>: the same search assearch_vault, as a resource (mimeType: application/json; optional&limit=,&vault=and&semantic=falseortrueto force grep or semantic search, likesearch_vault'ssemantic; any other value, alimitthat is not a whole number from 1 to 200, a parameter with no value, or any other parameter (a misspeltlmit=5) is-32602, naming the closest one; so is a parameter given twice (q=a&q=bused to search forbalone) and a malformed escape (q=%ZZused to search for the literal%ZZ; write a literal%as%25, a+as%2B)). Returns{ results, mode, query, note?, vault?, truncated?, depthCut? }, with the samenote,truncatedanddepthCutassearch_vaultwhen it fell back or did not search every file; an error when the app is not running and novaultis given.mdopener://recent-documents: recently opened files that still exist, each withpath,title,mtimeMs, and a 200-characterpreview(mimeType: application/json), as{ documents, count, skipped }.titleis the text of the first level 1 ATX heading outside YAML front matter, fenced code blocks, blockquotes and list items (one on a list item's continuation line,- x, a blank line, then# Sub, used to be the title), else the file name without its extension (a#line in front matter or a code block used to be taken as the title). A heading has at most three spaces before its#and a space, a tab or the end of the line after it, and its closing#s are dropped, as in CommonMark andexport_metadata's headings; only#at the start of a line used to count, so# Titleand#<tab>Titlewere not titles. Recents that cannot be listed are left out and counted inskippedby reason:missing(no longer on disk),notDocument(a dotfile, not Markdown, or not a regular file),tooLarge(past 16 MiB) andunreadable(any other read failure, such as text that is not UTF-8). An app with no recents gives an empty list; an app that cannot be reached, that refuses this server's token (HTTP 401) or that answers any other HTTP error is-32000, as formdopener://current(it used to be an empty list too).file://<path>(vault files): every file the app lists in the vault (watched folder) is listed as an individually readable resource. The path in the URI is percent-encoded as RFC 3986 and RFC 8089 require: every byte but a letter, a digit,-._~,!$&'()*+,;=,:,@and the/separator is written%XX, so/notes/My Notes 100%.mdis listed asfile:///notes/My%20Notes%20100%25.mdandcafé.mdascaf%C3%A9.md. A Windows pathC:\notes\a.mdisfile:///C:/notes/a.md, and a share\\srv\share\a.mdisfile:////srv/share/a.md. The URIs used to befile://followed by the path as is, which is not a valid URI when the path has a space;resources/readstill takes that form. Recently opened files are not listed asfile://entries (readmdopener://recent-documentsfor them), but a recent'sfile://<path>is still readable throughresources/read. When the app cannot be asked for them (it is not running, or it refused), the list has none, and the result carries_meta: { "mdopener/vaultFilesUnlisted": "<reason>" }(also logged on stderr) so a client can tell that from a vault with no files; a listing that worked has no such key. When the app's vault list is cut short (the watched folder has more Markdown files than the app lists, or is too large or too deeply nested to scan in full, assearch_vault'struncatedsays), the result carries_meta: { "mdopener/vaultFilesTruncated": "<reason>" }, naming the app's cap, so thefile://entries are not read as the whole vault.
resources/read is scoped: a file:// URI is only readable if it is one of
the paths the app currently advertises in the vault or recents. Any other path
is rejected (-32002, saying to open it with open_file first), so the resource channel can't be used as an
arbitrary-filesystem read primitive. When the app cannot be reached it lists
no path, and the read is -32000 saying the app is not running, or with
the refused-token message when the app answers HTTP 401, as for
mdopener://current (it used to be -32002 "not in vault"). The
percent-encoded URIs resources/list gives (file:///notes/My%20Notes.md),
the same path written unencoded (file:///notes/My Notes.md, the form it
used to give, tried first) and file://localhost/... are accepted, and a
file:///C:/... or file:////srv/share/... URI names the Windows path the
app lists; the check runs on the decoded path, so encoding cannot reach a
file outside the vault. A malformed escape (%ZZ, a lone %; write a literal % as %25) or
escapes that do not decode to UTF-8 (%FF) are refused with -32602 naming
the problem, unless the URI is an advertised path exactly as written. An advertised path is then read only if it is a Markdown document that
is not a dotfile, checked after resolving symlinks (like the tools that read a
path from disk), so a vault notes.md that links to ~/.ssh/id_rsa or .env
is refused (-32002). mdopener://recent-documents skips such a recent
instead of previewing its target.
Prompts reference
The server implements prompts/list and prompts/get. Each prompt embeds the
live document so the returned message is self-contained:
| Prompt | What it asks the model to do |
summarize | Summarize the current document into a short bulleted list of key points. |
review_plan | Review the current document as an implementation plan; flag risks, missing steps, and anything ambiguous. |
improve_writing | Tighten the prose and fix grammar of the current document without changing its meaning or structure. |
prompts/get returns a single user message whose text is the instruction
followed by the current document content. With no document open it is
-32002 ("No document is open"), and -32000 when the app is not running
or answers HTTP 401 (it refuses this server's token, and the message says
how to fix it), the same errors mdopener://current gives; any other
HTTP error the app answers is -32000 too, with the app's reason as the
message. No prompt takes arguments: arguments may be left out, null or
{}, and any argument given (such as a path, which used to be dropped
while the prompt ran on the open document) is refused with -32602; open
the document you mean with open_file first. Each prompt's prompts/list
description says so too, -32000 included (it used to name only a closed
app).
Human review loop
request_review is the heart of the agent-with-a-human-in-the-loop workflow. It
lets an agent stop and get explicit human sign-off on a plan, diff, or doc
before proceeding.
Flow
- Agent calls
request_reviewwith either a filepathor inlinecontent. The MCP server registers the review with the app (a uniquereviewIdis generated) and, if apathwas given, opens that file in read mode. The app shows its review panel. If the app cannot open the file, no review is registered and the call returnsisError: truewith the app's reason. - The call BLOCKS (when
blockingistrue, the default). Under the hood the MCP binary polls the app every ~1.5s for a verdict, for at mostwait_ms(default 50 s, below the 60 s request timeout of common MCP clients). If the human has not decided by then it returns{ "status": "pending", "reviewId": "…" }; callrequest_reviewagain withreview_idto keep waiting on the same review. - The human acts in the app, clicking Approve, Request changes, or Dismiss, optionally leaving comments.
- The agent receives the verdict as the tool result:
{ "verdict": "approved" | "changes_requested" | "dismissed", "reviewId": "…", "comments": "…" }.dismissedmeans the human closed the panel. If the panel's countdown runs out with no decision, the verdict istimeout. Atimeoutnever replaces a verdict already given. If the human still decides after the window has closed (an agent may already have been toldtimeout), that verdict is recorded with"late": trueand amessagesaying so, in therequest_reviewresult of a resumed call and inget_user_annotations. - One review at a time. The panel shows a single review. When another
request_review(from this agent or another) reaches the panel before the human has decided, the review it replaces ends at once with{ "verdict": "superseded", "reviewId": "…", "supersededBy": "…", "message": "…" }(also when resumed withreview_id, and inget_user_annotations), rather than waiting out its window and reportingtimeout. Callrequest_reviewagain if it still needs sign-off. Reviews are not queued: a queued review's window would run out while it is hidden, and the human sees the newest request, which is usually the one that matters. A verdict the human was already submitting as the panel switched is still recorded, markedlate.
Parameters
pathorcontent: one is required.pathopens that file for review;contentreviews inline Markdown, which the review panel shows as text (it used to show only "inline content", so the human never saw what they were approving).blocking(defaulttrue): iffalse, the review is registered and the call returns immediately with{ "reviewId": "…", "status": "pending", "message": "…" }. Callrequest_reviewwith thatreview_idto wait for the verdict. A resumed call withblocking: falsedoes not wait: it reads the review once and returns its verdict,{ "status": "pending", … }, ortimeoutonce its window has closed, ignoringwait_ms. A review of apathcan also be read later viaget_user_annotations; one of inlinecontenthas no path, so onlyreview_idfinds it.timeout_ms(default300000= 5 min, must be 5 000 to 600 000): how long the human has to decide (the review panel's window). A value outside that range (or not a whole number) is refused with an invalid-params error naming the range, and no review is registered; the app's/reviewrefuses the same values with a 400. When the window closes during a call (the first one, or one resumed withreview_id) with no decision, the tool returns{ "verdict": "timeout", … }, so resuming a review never waits past its window.wait_ms(default50000, must be 1 000 to 600 000): the most one call blocks. Raise it only if your client allows longer tool calls. A value outside that range (or not a whole number) is refused with an invalid-params error naming the range, and no review is registered; it used to be clamped in silence. To return without waiting, passblocking: false.review_id: resume waiting on a review an earlier call returned as pending (no new review is registered). An empty or blankreview_idis refused with-32602; it used to be read as absent, so the call registered a new review that replaced the pending one. A resumed call cannot change the review, sopath,contentandtimeout_msmay be resent unchanged (or left out), and one that differs from what the review was requested with is refused with an invalid-params error naming it, and nothing is waited on. Refusing, rather than going ahead and noting it, is deliberate: an agent that changed itscontentand kept the oldreview_idwould otherwise read the human's verdict on the old text as a verdict on the new one. The server remembers the last 256 reviews it registered; for a review it did not register (it was restarted, or another server registered it) nothing can be compared, so the call goes ahead and the result lists those arguments asignored, with anote. Once itstimeout_mswindow has closed with no verdict, the call returns{ "verdict": "timeout", … }. The app keeps a review for 30 minutes after itstimeout_mswindow closes, so resume within that time; later the call returnsverdict: "timeout"(the record is gone). The newest review of each file is kept longer, forget_user_annotations, and the app holds at most 256 reviews, dropping the oldest decided ones first.
Reading the result later
get_user_annotations(path) returns the latest verdict and comments recorded
for a file (with late: true when that verdict came after its review window
closed), plus the current state of its GFM task checkboxes
(- [ ] / - [x], including */+ bullets). Use it after a non-blocking
request_review, or to re-check whether the human has ticked off task items.
When Ashlr MD is not running, get_user_annotations still answers, from
disk: tasks are the checkboxes saved in the file, and verdict, comments
and late are the last review the app saved for that file in
~/.mdopener/reviews/ (null when none was saved), with appRunning: false
and a note. The app saves a review of a file there when it is decided or
superseded: one owner-only (0600, folder 0700) JSON file per document,
at most 256 KiB, written atomically; a newer review of the same file always
wins, and a file that is oversized, corrupt, or names another path is
ignored. A saved review adds saved: true, reviewId, reviewedAt (ms
since the epoch), supersededBy for a superseded review, and stale. stale
is true when the file changed since the review (its content hash differs) or
cannot be compared: the verdict was for an earlier version, so a stale
approved is not approval of the file as it is now, and the note says to
call request_review (open_file starts the app) to ask again. A review of
inline content (no path) is not saved. A file
that cannot be read (missing, not a Markdown document, or larger than 16 MiB)
has empty tasks and a tasksNote saying why, in the words the other disk
tools use (for a missing file, "Cannot read '/abs/plan.md': the file does not
exist. Its tasks were not read."). An app that answers HTTP 401
(it refused the token in ~/.mdopener/ipc-token: the file is stale, or the
port belongs to another Ashlr MD instance) is read from disk the same way, but
the result has appAuthFailed: true instead of appRunning, and its note
says to ask the user to quit every Ashlr MD window and start the app again
(which writes a new port and token), then call get_user_annotations again.
Any other error from the app is still an error.
Tips for agents
- Use
request_reviewfor gates: "Here's the plan / the diff / the final doc. Approve before I continue." - Pair it with
present_documentwhen you want the human to read a polished result without the app's chrome, then callrequest_reviewfor the sign-off. - On
changes_requested, read the returnedcomments, apply fixes withedit_document, and request review again.
Auto-open hook
mdopen --hook is a Claude Code PostToolUse hook: whenever Claude writes or
edits a Markdown file, the file auto-opens in Ashlr MD for review. The hook is
silent and exits 0, so it never disrupts Claude's tool flow: it reads
the PostToolUse JSON from stdin, extracts the written file path, and opens it in
read mode: through the running app's loopback IPC channel, or via the
mdopener:// scheme when the app is not running. It reads at most 64 MiB of
stdin; a longer payload opens nothing (still silently, exit 0). It acts only
on a PostToolUse payload from Write, Edit or MultiEdit: a payload whose
hook_event_name is another event (a hook wired as PreToolUse would open the
file before the tool changed it) or whose tool_name is another tool (Read
carries a file_path too) opens nothing, so a broad hand-written matcher does
not open every Markdown file the agent reads. A payload whose tool_response
reports the tool failed (success: false, is_error: true, a non-empty
error, or a <tool_use_error> string) opens nothing either, since a failed
Edit leaves the file unchanged. A payload without those fields is judged by
its tool_input.file_path alone. The hook command takes no arguments besides
--hook: mdopen --hook notes.md is a usage error (exit 1, naming the
extra argument on stderr, which Claude Code reports without blocking the
tool call), since the payload arrives on stdin.
One-click install
Preferences → AI agents (MCP) → Auto-open agent Markdown. Ashlr MD merges this
into ~/.claude/settings.json (idempotently: it won't add a duplicate).
If the hook it installed earlier runs an mdopen that no longer exists (the app
was moved, for example from Downloads to Applications), clicking it again points
the hook at this copy of the app; a hook running another mdopen that exists is
left alone:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "'<mdopen>' --hook",
"if": "Write(*.md)|Edit(*.md)"
}
]
}
]
}
}The matcher fires on the Edit and Write tools; the if guard narrows it to
*.md files. The mdopen path is single-quoted so the space in "Ashlr MD" does
not split it; re-running the installer repairs an unquoted entry that earlier
versions wrote for the same path. After installing, restart Claude Code to
pick it up. The quoting targets the POSIX shell Claude Code runs hooks with; it
has not been verified on an installed Windows build.
From an AppImage the button does not write the mdopen inside the
AppImage's temporary mount (/tmp/.mount_*), which is gone once the app
quits: it copies mdopen to ~/.local/share/app.mdopener.desktop/bin/mdopen
and writes that path, refreshing the copy when you click it again after
updating the AppImage.
If
~/.claude/settings.jsonalready has a non-objecthooksvalue or a non-arrayhooks.PostToolUse, the installer bails with an error rather than clobbering your settings; fix the shape manually and retry.
The mdopen CLI
mdopen opens any Markdown file in Ashlr MD from a terminal, CI script, or agent
tool call.
When the app is running, mdopen hands it the file over the token-authenticated
loopback IPC channel, so it works even where the mdopener:// scheme is not
registered (an AppImage or dev build on Linux). Otherwise it opens the
mdopener:// URL, which launches the app. It exits 1 when neither works;
for mdopen - the temp file holding stdin is then removed. The message names
the file, what the running-app route and the mdopener:// route each hit,
and ends with the next step (start Ashlr MD, then run mdopen again). It also
exits 1, before contacting the app, for a file that is not a Markdown document
(.md, .markdown, .mdown, .mkd, .mdx) or is a dotfile, checked after
symlinks are resolved; and when the running app refuses the file, it exits 1
with the app's reason on stderr without trying the URL scheme. It also exits
1 without trying the URL scheme when the running app was reached but
failed: it answered with a server error, the connection broke, or it did not
answer within 6 s. The app may still open the file then, so for mdopen -
the temp file is kept. When the running app refuses the token (HTTP 401),
mdopen still tries the mdopener:// URL, and if that opens the file it
exits 0 but prints the stale-token problem and its fix to stderr ("IPC auth
failed: Ashlr MD refused the token in ~/.mdopener/ipc-token (HTTP 401), ...;
quit every Ashlr MD window and start it again, which writes a new token. The
file was opened through the mdopener:// URL instead."), as it reports an
unconfirmed launch; it used to say nothing, so the stale token went unnoticed.
--hook stays silent. mdopen - also
exits 1, opening nothing, when stdin is empty or only whitespace, or longer
than the 16 MiB a document may be (it stops reading there and writes no temp
file). Run with a
terminal on stdin (nothing piped), it prints how to finish typing (Ctrl+D, or
Ctrl+Z and Enter on Windows) to stderr. Run with no arguments at all, it prints its help to stderr and exits
1; mdopen --help prints it to stdout and exits 0. An empty or blank
path (mdopen "$FILE" with an empty variable) is a usage error saying the
path is empty, and a directory is refused as a directory (exit 1 for
both). An argument that is
not valid UTF-8 is a usage error (exit 1, naming it on stderr): Ashlr MD
takes paths as UTF-8 text, and such an argument used to crash mdopen with
exit 101, --hook included.
Install
In Ashlr MD: Preferences → Command-line tool → Install mdopen (all platforms).
From an AppImage, the link it makes points at the copy of mdopen in
~/.local/share/app.mdopener.desktop/bin/, not inside the AppImage's mount.
Or via the build-from-source installer (macOS/Linux):
bash <(curl -fsSL https://raw.githubusercontent.com/ashlrai/ashlr-md/main/scripts/install.sh)On Windows, the app installer places mdopen.exe alongside the app, and
Install mdopen copies it to %USERPROFILE%\.local\bin\mdopen.exe. Neither
folder is on PATH by default, so add one of them to your user PATH yourself.
Usage
mdopen README.md # open a file (read mode, default)
mdopen --edit PLAN.md # open directly in edit mode (-e also works)
mdopen --read NOTES.md # force read mode (-r also works; with --edit it is a usage error)
cat notes.md | mdopen - # pipe stdin → temp file → open in app
mdopen -- -draft.md # `--` ends flags, for file names starting with `-`
mdopen -- - # after `--`, `-` is a file named `-`, not stdin
mdopen --hook # Claude Code PostToolUse hook mode (reads JSON on stdin)
mdopen --help # print usage (-h also works); wins over every other flag, --hook included
mdopen --version # print the version (-V also works)From an agent
Claude Code (and other agents with shell access) can call mdopen directly:
# In a Python agent tool
import subprocess
subprocess.run(["mdopen", "/path/to/output.md"], check=True)Running under the Phantom workbench or phantom exec? The detection paths,
the phm_ placeholder boundary and the exact exit-status contract Phantom
relies on are in PHANTOM.md.
The mdopener:// URL scheme
Any app (browser, terminal, script) can open a file in Ashlr MD via the custom URL scheme:
mdopener://open?path=/absolute/path/to/file.md
mdopener://open?path=/path/to/file.md&mode=editFrom the terminal:
# macOS
open "mdopener://open?path=$(pwd)/README.md"
# Windows (PowerShell)
Start-Process "mdopener://open?path=$PWD\README.md"
# Linux
xdg-open "mdopener://open?path=$(pwd)/README.md"From JavaScript / Electron:
shell.openExternal(`mdopener://open?path=${encodeURIComponent(filePath)}`);Since any web page or program can launch these links, a link opens only an
existing Markdown document that is not a dotfile (checked after resolving
symlinks, as for open_file); anything else is ignored. The path is
percent-decoded once. mode is read or edit and switches the document
to that view once it is open, also when the link starts the app (so does
mdopen --edit with the app closed); a link with any other mode is
ignored, as open_file refuses one. mode used to be dropped, so the file
opened in whatever view it was. Other parameters are ignored, by open and
export links alike, and of a parameter given twice the first counts.
mdopener://export?path=<path>&format=pdf|docx|html|epub opens the file and,
once it has loaded (also when the link starts the app), the export dialog
with that format selected (format defaults to pdf). The export request
used to be sent with the open, so a link that started the app only opened
the file. An export link always switches the file to Read view, which
exporting needs: mode may be left out or be read, and a link with
mode=edit (or any value an open link refuses) is ignored. It used to
ignore mode and export from whatever view the file was in, which fails in
Edit view. A link with any other format (PDF, md) is ignored, as the
export tool refuses one; it used to open the dialog with no format
selected.
How it works: the IPC model
The MCP binary communicates with the running Ashlr MD app over a loopback HTTP
server bound to 127.0.0.1 on an OS-assigned port. On startup the app writes
its port to ~/.mdopener/ipc-port and a per-session token to
~/.mdopener/ipc-token; the MCP binary reads both to locate and authenticate to
the app, and the app removes them on clean exit.
If the app is not running, most tools return a clear error; open_file is the
exception and will cold-start the app via the mdopener:// URL scheme. The
frontend keeps the app's document, recents, and vault state mirrored to the IPC
layer (via mcp_sync_state / mcp_sync_vault) so reads are always fresh without
re-walking the disk.
Security
The integration is local-only and token-authenticated:
- The IPC server binds to
127.0.0.1(loopback) on an ephemeral port, so nothing is exposed beyond your machine. - On startup the app generates a 32-byte CSPRNG token (64 hex chars) and
writes it to
~/.mdopener/ipc-token. On Unix the file is created owner-only (0600) in a single syscall so the token is never momentarily world-readable. - Every endpoint except
/health(a data-free liveness probe) requiresAuthorization: Bearer <token>. The token is compared in constant time to avoid leaking a timing signal, and a mismatch returns401. - The MCP
resources/readchannel is scoped to advertised vault/recents files (it cannot be used to read arbitrary paths off disk) and, like the tools below, it reads only Markdown non-dotfiles after resolving symlinks. - Conversation memory under
~/.mdopener/sessionsis owner-only: the server keeps the directory0700and writes each session file0600. - Searches (
search_vault,text://vault-searchand the app's/search) read only the vault files and recents that are Markdown documents and not dotfiles, checked after resolving symlinks, so a recent.envor anotes.mdlink to a key is never searched. - The tools that read a path straight from disk (
diff_documents,export_text/export_metadatawithpath,search_vaultwithvaultwhen the app is unreachable, and the task list ofget_user_annotationsfor a file that is not the open document) only read Markdown files (.md,.markdown,.mdown,.mkd,.mdx) that are not dotfiles. The check runs after symlinks are resolved, so anotes.mdlink to.envis refused withisError: true. The read then takes only the file the check saw: on Unix a link put in the file's place afterwards is not followed (O_NOFOLLOW), and a file with another device and inode (its folder swapped for a link, say) is refused. The app's/searchand/annotationsread the same way. The tools that ask the app to open a path (open_file,present_document,request_reviewwithpath) make the same check, since the app would then show the file andget_current_contentwould return it; the app's/open,/presentand/reviewendpoints refuse such a path too (HTTP 400, and the reason reaches the agent).edit_canvaslikewise re-checks.canvasafter resolving symlinks, and saves by renaming a new temp file over the canvas (keeping its permissions), so a canvas swapped for a symlink after the check is replaced, never written through. exportwithoutput_pathwrites that file without a dialog, and replaces an existing file of that name only withoverwrite: true. It only accepts a non-dotfile path with the format's own extension (.html/.htm,.docx,.epub) in an existing directory, so it cannot overwrite Markdown or other file types. The app's/exportendpoint applies the same rule tooutputPath(which must also be absolute) and refuses anything else with HTTP 400, whoever calls it. It replaces a file already atoutputPathonly when the request says"overwrite": true; without the flag (a caller written before it existed) the file is refused, not replaced.- Every disk read (those tools,
edit_canvas,file://resources, the recent-documents resource and the local grep fallback) opens regular files only, so a FIFO or device namedx.mdis refused (or skipped) instead of blocking the server. Each also refuses (or, for search and the recent-documents resource, skips) a file larger than 16 MiB, the most the app accepts in one request, instead of reading it whole into memory and into the reply. Session files are read up to 16 MiB too. The app reads the same way when the human opens a document or searches from the window: regular files only, opened without blocking, at most 16 MiB (the document/contentreturns was read that way). - An answer from the app's IPC server (or from Ollama) is read up to 64 MiB, headers included; a longer one is refused ("more than 64 MiB") instead of being read into memory for as long as the peer keeps sending.
- The token and port files are removed on clean app exit, so a stale process can't authenticate against a new session.
Dev / build-from-source
# Full install (macOS: builds and installs the app, sidecars, and CLI;
# elsewhere: CLI + MCP only, get the app from Releases):
bash <(curl -fsSL https://raw.githubusercontent.com/ashlrai/ashlr-md/main/scripts/install.sh)
# Or from a checkout:
bash scripts/install.sh
# CLI + MCP only (no Tauri app build):
SKIP_APP_BUILD=1 bash scripts/install.shThe install script requires Rust (rustup), Bun and git. It is a
bash script for macOS and Linux. On macOS the full install also needs Xcode
(see the script header). On Linux it builds and installs only mdopen and
mdopener-mcp (a C toolchain such as build-essential is enough) and says so;
install the app itself from a .deb, .rpm or .AppImage release.
In a dev build the MCP binary lives at:
src-tauri/target/release/mdopener-mcpRegister it with Claude Code:
claude mcp add --scope user ashlr-md \
"$(pwd)/src-tauri/target/release/mdopener-mcp"Troubleshooting
"binary not found" after connecting The MCP binary must exist before the agent can launch it. Either:
- Install the full app from a release DMG or
scripts/install.sh, or - Run
cargo build --release --manifest-path src-tauri/Cargo.toml -p mdopener-mcpfrom the repo root and register thesrc-tauri/target/release/mdopener-mcppath manually.
Tools return "Ashlr MD does not appear to be running"
The app must be open for everything except open_file. Launch Ashlr MD (or call
open_file, which cold-starts it), then retry. A tool that was given a path
(request_review, present_document, edit_document, stream_edit,
stream_edit_apply) names the call to make with that exact path, for example
Call open_file with {"path":"/abs/plan.md"}, which starts it with this
document, then call this tool again. stream_edit_apply says instead to call
stream_edit again for a fresh preview before applying: the editId of a
preview from before the app started no longer exists, and the document may
have changed since. The message also says that an
app that is not installed comes from
the releases page.
Tools return "Ashlr MD answered on the port in ~/.mdopener/ipc-port but refused the token"
The app answered HTTP 401: ~/.mdopener/ipc-token is stale, or the port
belongs to another Ashlr MD instance. Every app tool returns this one message
(it used to be the bare "IPC auth failed: token mismatch or missing
~/.mdopener/ipc-token"), and it says the next step: ask the user to quit every
Ashlr MD window fully and start the app again, which writes a new
~/.mdopener/ipc-port and ~/.mdopener/ipc-token, then retry the call.
open_file returns it too, without trying the mdopener:// URL. A
request_review that hits it while waiting adds that restarting ends the
review, so request a new one. get_user_annotations does not fail on it: it
reads the tasks from disk and says so with appAuthFailed: true, and
search_vault with vault searches that folder on disk, with a note
saying why and how to fix it; without vault, its error adds "To search
without the app, pass vault", as it does for an app that is not running.
Claude Code shows "ashlr-md: failed to start"
Run claude mcp list to see the registered path, then verify the binary exists
at that path and is executable (chmod +x). Running
<path> --version prints mdopener-mcp <version> and exits, so it is a quick
check that the path is right (--help prints usage). Any other argument is
ignored, so an old client config that passes one still starts the server, but
it is named on stderr (ignoring unknown argument ...); look there in the
client's MCP log for a typo such as --verison. That holds for an argument
that is not valid UTF-8 too (it used to stop the server with a panic). Run from a terminal, it says
on stderr that it is waiting for JSON-RPC on stdin; stdout carries only
protocol messages.
Cursor MCP panel shows the server as offline
Restart Cursor. If the issue persists, open ~/.cursor/mcp.json and confirm the
command path points to the binary.
Auto-open hook didn't install
If ~/.claude/settings.json has a malformed hooks / hooks.PostToolUse shape,
the installer bails rather than overwrite it; fix the JSON manually and retry.
mdopen command not found after installing
On macOS/Linux, make sure /usr/local/bin (or ~/.local/bin) is on your $PATH:
echo $PATH # check
mdopen --help # verifyOn Windows, add the folder holding mdopen.exe (%USERPROFILE%\.local\bin
after Install mdopen, or the app's install folder) to your user PATH, then
restart your terminal. The in-app installer does not change PATH.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

