Install
$ agentstack add mcp-planexhq-markdown-mcp Open-source listing — not yet scanned by AgentStack. Follow the source repository for install instructions.
Security review
⚠ Flagged1 finding(s); flagged for manual review. · v0.1.0 How review works →
- • Prompt-injection patterns
- • Secret / credential exfiltration
- • Dangerous shell & filesystem operations
- • Untrusted network calls
- • Known-malicious package signatures
- high Possible prompt-injection directive.
What it can access
- ● Network access Used
- ● Filesystem access Used
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
markdown-mcp
[](https://www.npmjs.com/package/markdown-mcp) [](https://nodejs.org) [](LICENSE) [](https://modelcontextprotocol.io)
Agents fed raw markdown end up paraphrasing from memory and fabricating citations. markdown-mcp gives them structured, addressable access to a local vault (Obsidian, Foam, plain folder) — heading-anchored fragments, BM25 search, stable IDs for re-fetching — so retrieved content stays verifiable.
Seven tools — get_vault_tree, get_file_outline, get_fragment, search, get_metadata, get_links, get_server_info — plus the note://{path} resource. Stdio transport, MCP spec 2025-06-18. Single-vault per process. v1 is read-only; write tools are planned for v2.
Features
- BM25 full-text search with frontmatter filtering (tags, dates, custom fields). Two modes: query and filter-only.
- Heading-anchored fragments addressed by
heading_pathorstable_id. Stale IDs recover via fuzzy fallback when files are edited. - Wikilink resolution + backlinks. Resolves Obsidian/Foam-style
[[note]],[[note#section]],[[note^block]]; surfaces incoming links per file or section. - OpenAPI 3.x + AsyncAPI 3.x + opaque YAML. Set
VAULT_EXTENSIONS=md,yaml,ymlto admit YAML alongside markdown. OpenAPI 3.x specs expose one fragment per operation (GET /pets,POST /pets); AsyncAPI 3.x specs expose one fragment per operation (send userSignedUp,receive lightMeasured); other YAML files index opaquely with the parsed top-level surfaced as frontmatter for filter queries. - Prisma schema (PSL). Set
VAULT_EXTENSIONS=md,prismato admit.prismafiles. Each top-level block (model,enum,view,type,datasource,generator) exposes one fragment (model User,enum Role,datasource db);///doc comments surface as prose;@@map("X")surfaces as aTable: Xline;datasource+generatorblocks surface as frontmatter so nested-path filters work (fields["datasource.db.provider"].eq: "postgresql"). - Async-reconcile startup. Server is up immediately; the index warms in the background. Bounded reads (outline, fragment, metadata) work during warmup.
- No Obsidian plugin required. Reads the vault directly from disk; works with any markdown folder.
- Fast. Sub-second warm restart on 10K-file vaults; search p95 ` (constant-time compare). Compatible with MCP hosts that speak Streamable HTTP per the 2025-06-18+ spec.
get_server_info.server.transport reports "http" (vs. "stdio") and surfaces the resolved bind_address + port so agents can self-verify.
Install (optional)
If you'd rather have a stable markdown-mcp binary on PATH (skips the ~1–2 s npx cold-cache fetch on first run):
npm install -g markdown-mcp
markdown-mcp --vault /path/to/your/vault
From source:
git clone https://github.com/planexhq/markdown-mcp.git
cd markdown-mcp
npm install
npm run build
node dist/index.js --vault /path/to/your/vault
Docker
Run markdown-mcp as an HTTP daemon in a container. The image is multi-stage (Debian-slim base, ~410 MB; the bulk is Node 22's runtime + better-sqlite3's compiled native binary) and runs as the non-root node user (UID 1000).
Quick start — HTTP daemon (Linux)
git clone https://github.com/planexhq/markdown-mcp.git
cd markdown-mcp
docker build -t markdown-mcp .
export MCP_AUTH_TOKEN=$(openssl rand -hex 32)
export VAULT_PATH=/absolute/path/to/your/vault
sudo chown -R 1000:1000 "$VAULT_PATH" # see "Vault mount + permissions"
docker compose up -d
The server is reachable at http://127.0.0.1:3000/mcp with Authorization: Bearer $MCP_AUTH_TOKEN. Verify the initialize handshake:
curl -s -X POST http://127.0.0.1:3000/mcp \
-H "Authorization: Bearer $MCP_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","clientInfo":{"name":"smoke","version":"0"},"capabilities":{}}}'
Platform notes
- Linux:
network_mode: hostmakes the container share the host's loopback.--bind 127.0.0.1inside the container =127.0.0.1on the host. - macOS / Windows (Docker Desktop): host networking routes through a Linux VM, so
127.0.0.1inside the container is the VM's loopback — not the host's. The HTTP daemon won't be reachable from host applications until a future network-bind ADR pairs0.0.0.0binds with mandatory auth + rate limiting. Today: use stdio mode (below) or run on Linux.
Environment
| Variable | Purpose | Default | |----------|---------|---------| | MCP_AUTH_TOKEN | Bearer auth (HTTP only). Constant-time compare. | unset = no auth | | MCP_HTTP_SESSION_IDLE_MS | Idle-session reclaim threshold | 1 800 000 (30 min) | | MCP_HTTP_SESSION_SWEEP_MS | Idle-session sweep interval | 60 000 (60 s) | | VAULT_EXTENSIONS | Comma-separated indexable extensions | md | | VAULT_TOKENIZER | Token estimator backend | heuristic/content-aware-v1 |
Vault mount + permissions
The container writes a .markdown-mcp/ cache directory (lockfile + SQLite + WAL/SHM) inside the mounted vault. The node user (UID 1000) needs write access. Two options:
# Option A — chown the vault on the host (persistent deployments;
# works for both compose and ad-hoc docker run)
sudo chown -R 1000:1000 /absolute/path/to/your/vault
# Option B — override the container user (development convenience).
# In compose.yaml (HTTP daemon):
services:
markdown-mcp:
user: "${UID:-1000}:${GID:-1000}"
Then UID=$(id -u) GID=$(id -g) docker compose up -d. For ad-hoc stdio launches, pass --user to docker run -i (the -i keeps stdin open so JSON-RPC framing survives; without it the server exits immediately on STDIN_EOF):
docker run -i --rm --user "$(id -u):$(id -g)" \
-v /absolute/path/to/vault:/vault \
markdown-mcp --vault /vault
Arguments after the image name replace the Dockerfile's CMD (Docker semantics). The stdio example above intentionally drops --transport http; stdio is the CLI default.
Stdio mode (MCP client launches the container)
The same image supports stdio. Point your MCP host at docker run instead of node:
{
"mcpServers": {
"vault": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-v", "/absolute/path/to/vault:/vault",
"markdown-mcp",
"--vault", "/vault"
]
}
}
}
-i keeps stdin open (required for JSON-RPC framing). Do not pass -t — a TTY corrupts the JSON-RPC frame stream.
Connect from an MCP host
Tested with Claude Desktop, Claude Code, Cursor, and Windsurf. Any MCP-compatible host that speaks stdio + protocol 2025-06-18 works.
> Windows users, for any of the host configs below: replace "command": "npx" with "command": "cmd" and prepend "/c", "npx" to args — npm's .cmd shim isn't directly spawnable from JSON config without it.
Claude Desktop / Claude Code
Add to your MCP config:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json
{
"mcpServers": {
"my-vault": {
"command": "npx",
"args": ["-y", "markdown-mcp", "--vault", "/Users/you/Documents/Vault"]
}
}
}
The -y flag skips npx's "Ok to proceed?" prompt so the host can spawn the server non-interactively. If you globally installed markdown-mcp, swap command: "npx" + the -y / markdown-mcp args for command: "markdown-mcp" and drop the first two args.
Cursor
Config file: ~/.cursor/mcp.json (global) or .cursor/mcp.json (project). Same shape as Claude Desktop:
{
"mcpServers": {
"my-vault": {
"command": "npx",
"args": ["-y", "markdown-mcp", "--vault", "/Users/you/Documents/Vault"]
}
}
}
VS Code
Config file: .vscode/mcp.json (workspace) or via MCP: Open User Configuration (global). Note the top-level key is servers, not mcpServers:
{
"servers": {
"my-vault": {
"command": "npx",
"args": ["-y", "markdown-mcp", "--vault", "${workspaceFolder}/vault"]
}
}
}
Other MCP-compatible hosts
Windsurf, Goose, Zed, and other stdio-based MCP hosts accept the same command + args shape — adapt the JSON key (mcpServers vs servers) per the host's docs.
CLI flags
| Flag | Purpose | |---|---| | --vault | Vault directory (required). Absolute or relative. | | --polling | Force fs polling instead of native FS events. Use on network mounts (NFS/SMB) and platforms where chokidar's native events fire unreliably. ~10× slower; only enable when needed. | | --include-hidden | Include dot-prefixed files and directories on every surface. Default excludes them. All-or-nothing per server. | | --prose-only | Suppress structuredContent on every tool response so the markdown prose body is the sole channel. Useful for token-constrained LLM-consumer workflows. get_server_info.server.prose_only reflects the flag for agent self-verification. | | --transport | stdio (default) or http. HTTP speaks Streamable HTTP per MCP spec 2025-06-18+; one process serves multiple concurrent sessions sharing one warm index. See [Transports](#transports). | | --port | HTTP listener port (default 3000; only with --transport http). Use 0 for OS-assigned. | | --bind | HTTP bind address (default 127.0.0.1; only with --transport http). v1 supports loopback only (127.0.0.0/8, ::1, localhost); anything else exits 2 at startup. | | -h, --help | Show usage and exit. |
Environment variables
| Variable | Purpose | |---|---| | VAULT_EXTENSIONS | Comma-separated list of file extensions treated as parseable notes (no leading dot, case-insensitive). Default: md. Examples: md,markdown, md,mdx, md,yaml,yml, md,prisma. Gates note://, get_vault_tree resource links, and every direct-read tool. YAML files route through OpenAPI 3.x synthesis, then AsyncAPI 3.x synthesis, then opaque emission. .prisma files route through Prisma schema synthesis. Changing this value forces a one-time cold rescan on next start. | | MCP_AUTH_TOKEN | Optional bearer token for HTTP transport. When set, every HTTP request must carry Authorization: Bearer (constant-time crypto.timingSafeEqual against the configured value). Unset → no auth (loopback-trust model). Stdio is unaffected. Read once at startup; restart to rotate. |
Tools
| Tool | Returns | |---|---| | get_vault_tree | Paginated DFS over the vault. Files, directories, dfsrank for stable cursors. | | get_file_outline | Full heading tree + block-ID index for one file (not paginated). | | get_fragment | Anchor-resolved fragment: heading, block, preamble, or whole file. Stableid with fuzzy stale-recovery. | | search | BM25 full-text + structured frontmatter filter. Two modes: query and filter-only. Discriminated-union response. | | get_metadata | Parsed YAML frontmatter for one file. | | get_links | Outgoing wikilinks + incoming backlinks. Optional narrowing by headingpath or stableid. | | get_server_info | Identity / health snapshot for agent self-verification: server version, vault root_hash, index state + freshness, algorithm IDs, registered tools. Zero input. |
The note://{path} Resource returns the raw on-disk markdown (frontmatter included) so hosts can stream a literal note when a parsed fragment isn't what they want.
OpenAPI 3.x YAML (when admitted via VAULT_EXTENSIONS): get_file_outline returns one node per operation (GET /pets); OpenAPI 3.1 webhooks join paths as Webhook: headings. get_fragment returns a synthesized prose rendering (summary, description, parameter prose) plus a compact JSON fence of the operation object; get_metadata returns the whole top-level spec so nested-path filters (fields["info.version"].eq) work directly. note://api/petstore.yaml returns the literal on-disk YAML with mimeType: application/yaml. Operations with a stable operationId keep their stable_id across path renames (e.g. /v1/pets → /api/v2/pets). Wikilinks into YAML are not yet resolved; other YAML files index opaquely (whole source searchable, top-level exposed as frontmatter).
AsyncAPI 3.x YAML (same admittance gate): get_file_outline returns one node per top-level operation (send userSignedUp, receive lightMeasured); get_fragment returns synthesized prose with the resolved channel address, message list, reply info, tags, plus a compact JSON fence of the full operation object. Intra-document $ref (#/channels/, #/channels//messages/) is resolved; external $ref renders verbatim. ## Channels and ## Components catch-all sections keep large specs navigable. AsyncAPI 2.x (with nested publish/subscribe) is deferred and falls through to opaque YAML emission.
Prisma schema (admit via VAULT_EXTENSIONS=md,prisma): get_file_outline returns one node per top-level block — model User, enum Role, view UserStats, type Address, datasource db, generator client. get_fragment renders block-level /// doc comments as prose, fields as a Fields: bullet list (with field-level /// as — suffix), @@map("X") as a Table: X line, block-level @@id / @@index / @@unique as a Block attributes: section, plus a compact JSON fence of the full AST subtree. get_metadata returns { datasource, generator } so nested-path filters work (fields["datasource.db.provider"].eq: "postgresql"). note://schema.prisma returns the literal on-disk PSL with mimeType: text/x-prisma. Wikilinks INTO .prisma are not yet resolved (same as YAML); free-floating /// not attached to any block surfaces in a trailing ## Schema notes section.
Typical agent flow
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ get_vault_tree │──▶│ get_file_outline │──▶│ get_fragment │
│ browse / scope │ │ pick a heading │ │ read the section│
└──────────────────┘ └──────────────────┘ └──────────────────┘
│ ▲
│ ┌──────────────────┐ │
└────────────▶│ search │───────────┘
│ query + filter │
└──────────────────┘
get_metadata = frontmatter only · get_links = follow citations
Examples
Inputs are the tool arguments the host passes; outputs are abbreviated tool results (the _meta envelope is omitted for brevity; nextCursor is shown when relevant).
Every tool returns two parallel channels: structuredContent (the typed JSON shown in each example below) for programmatic consumers, and content[0].text (a compact markdown rendering of the same data) for LLM consumers that read the prose channel directly. Pass [--prose-only](#cli-flags) to drop structuredContent and keep only the prose body. The prose channel uses a few rendering conventions:
- File-with-heading addresses join on
›(e.g.notes/auth.md › OAuth2). When a filename contains›, the file portion is always wrapped in«…»so the boundary stays unambiguous — both standalone (e.g.[file] «notes/foo › bar.md»inget_vault_tree,«notes/foo › bar.md» · fileinsearchfile/preamble rows) and inside addresses («notes/foo › bar.md» › OAuth2). Passing either form back through afile:argument round-trips to the same path. - Wikilink aliases are quoted JSON-style (
"alias text"), so embedded"and\are escaped (\",\\). get_metadatarenders frontmatter as a YAML block whose entirecontent[0].textreparses as valid YAML — header and meta footer are#-prefixed comments.
get_vault_tree — browse the vault
// in
{ "path": "projects", "pageSize": 5 }
/
…
## Source & license
This open-source MCP server is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [planexhq](https://github.com/planexhq)
- **Source:** [planexhq/markdown-mcp](https://github.com/planexhq/markdown-mcp)
- **License:** MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.