A complete guide to building a Workflowy MCP server — and the Claude skills that teach Claude how to actually use it. Read, search, and write your entire outline, from any Claude interface.
An MCP server is a small program Claude runs in the background. It exposes a set of tools Claude can call. But tools alone aren't enough — Claude also needs to know how your outline is shaped and when to reach for which tool. That knowledge lives in skills. Build both and Claude becomes fluent in your Workflowy.
A Node.js process that talks to the Workflowy API and keeps a local SQLite mirror of your whole outline for instant, token-free search.
Markdown files that give Claude the map: what your columns mean, where things live, and the exact tool to use for each request.
You don't need to be a developer, but you do need these installed and one key in hand.
node --version. Get it at nodejs.org.Six steps: get a key, scaffold the project, understand the one idea that makes it fast, define the tools, and compile. Roughly an afternoon by hand — minutes if Claude drives.
Log in, visit beta.workflowy.com/api-key, and generate a key. It's a long token — treat it like a password. You'll paste it into the config later, never into your code.
config.json (git-ignored) or an environment variable.
Create a folder and initialize it. This is the entire skeleton — a src/ tree, a build config, and a package manifest.
# create the project mkdir workflowy-mcp-server && cd workflowy-mcp-server npm init -y # folders for tools, services, and compiled output mkdir -p src/tools src/services
Your target structure looks like this — each file owns one job:
workflowy-mcp-server/ ├── package.json ├── tsconfig.json ├── config.json.example ← template users copy & fill └── src/ ├── index.ts ← entry: MCP server + stdio ├── config.ts ← loads API key + bookmarks ├── constants.ts ← API URLs, defaults ├── types.ts ← shared TypeScript interfaces ├── services/ │ ├── api-client.ts ← Workflowy API wrapper + rate limit │ ├── cache.ts ← SQLite mirror + FTS5 search │ └── sync-engine.ts ← keeps cache fresh └── tools/ ├── read.ts ← get node, list children ├── search.ts ← full-text search ├── write.ts ← create / edit nodes ├── complete.ts ← mark done / undone ├── bookmarks.ts ← named shortcuts └── sync.ts ← status + force resync
Deliberately minimal — the MCP SDK, a fast synchronous SQLite driver, a rate limiter, and Zod for validating tool inputs. Node's built-in fetch handles HTTP, so there's no axios.
npm install @modelcontextprotocol/sdk better-sqlite3 bottleneck zod npm install -D typescript @types/node @types/better-sqlite3
Add a tsconfig.json and the build/start scripts:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
"type": "module", "scripts": { "build": "tsc", "start": "node dist/index.js", "dev": "tsc --watch" }
This is the design decision that makes the whole thing usable. The Workflowy API is rate-limited and slow for big outlines. So instead of asking the API every time, the server keeps a local SQLite copy of your entire tree and answers reads & searches from that copy — instantly, and at zero token cost to Claude.
Claude ⇄ stdio ⇄ MCP server
│
reads/search│ ┌───────────────┐
├─────────▶│ SQLite cache │ ← instant, no API
│ │ (FTS5 search) │
write │ └───────────────┘
▼ ▲
┌──────────────┐ sync │
│ Workflowy API│───────────┘
└──────────────┘
The cache is a single table plus a full-text index. Writes go through the API and update the cache in the same breath (write-through), so new nodes are searchable immediately.
CREATE TABLE nodes ( id TEXT PRIMARY KEY, -- Workflowy node id parent_id TEXT, -- for breadcrumbs name TEXT, -- the node text note TEXT, priority INTEGER, -- sibling sort order layout_mode TEXT, -- bullets / todo / h1..h3 completed_at INTEGER, -- null = active modified_at INTEGER, cached_at INTEGER NOT NULL ); -- full-text search: this is what makes search instant CREATE VIRTUAL TABLE nodes_fts USING fts5( id UNINDEXED, name, note, content='nodes', content_rowid='rowid' );
Tools are the verbs Claude can perform. Keep the set small and unambiguous — one obvious tool per intent, so Claude never has to guess. A lean, opinionated toolset like this works better than a sprawling one:
| Tool | Type | What it does |
|---|---|---|
wf_find_node | Search | The primary way in. Cascading search: exact match → date → full-text. Returns nodes with breadcrumb paths. |
wf_smart_create | Write | Creates a whole nested subtree in one call, then refreshes the cache so it's instantly findable. |
wf_get_node | Read | Fetch one node's details by id. |
wf_list_children | Read | Direct children, sorted by priority, completed hidden by default. |
wf_list_bookmarks | Bookmark | List your named shortcuts to key areas of the outline. |
wf_get_bookmark | Bookmark | Resolve a bookmark → return that node's children. |
wf_complete_item | Complete | Mark a node done (API + cache). |
wf_uncomplete_item | Complete | Un-mark a completed node. |
wf_sync_status | Sync | Report cache size + last sync time. |
wf_force_sync | Sync | Full resync from the API on demand. |
Each tool registers with the MCP SDK, declares its inputs with a Zod schema, and returns text. Here's the shape — wf_find_node as the model:
import { z } from "zod"; export function registerSearch(server, cache) { server.tool( "wf_find_node", "Search your Workflowy. Use this to LOCATE any node.", { query: z.string(), limit: z.number().max(100).default(20) }, async ({ query, limit }) => { // all local SQLite — zero API calls, zero rate limit const hits = cache.search(query, limit); return { content: [{ type: "text", text: formatWithBreadcrumbs(hits) }] }; } ); }
stderr, never stdout — stdout is reserved for the MCP protocol. 2. Always return breadcrumb paths with search results (Reference › Work › Client › Task) so Claude knows where a node lives.
Ship a config.json.example so users know what to fill in. The key can also come from an environment variable, which takes precedence.
{
"workflowy_api_key": "YOUR_API_KEY_HERE",
"bookmarks": {
"tasks": { "node_id": "", "label": "Tasks" },
"capture": { "node_id": "", "label": "Inbox / Capture" },
"reference": { "node_id": "", "label": "Reference" }
},
"cache_path": "./workflowy-cache.db",
"sync_interval_minutes": 60
}
To find a bookmark's node_id: open that bullet in Workflowy and copy the last segment of its URL. Then compile:
cp config.json.example config.json # then paste your key + ids npm run build # tsc → dist/ node dist/index.js # should hang, waiting on stdio = correct
node dist/index.js sits there silently, it's alive and waiting for Claude to speak to it. Press Ctrl+C and move on to Part 2.
You register the server once per interface by pointing Claude at your compiled dist/index.js and handing it your API key. Use the absolute path.
Edit the config file (create it if it doesn't exist), then fully restart Claude Desktop.
%APPDATA%\Claude\claude_desktop_config.json~/Library/Application Support/Claude/claude_desktop_config.json{
"mcpServers": {
"workflowy": {
"command": "node",
"args": ["/absolute/path/to/workflowy-mcp-server/dist/index.js"],
"env": { "WORKFLOWY_API_KEY": "your-key-here" }
}
}
}
Register it with one command — Claude Code writes the config for you:
claude mcp add workflowy node /absolute/path/to/dist/index.js \ --env WORKFLOWY_API_KEY=your-key-here
Then type /mcp inside Claude Code to connect and confirm the workflowy server shows as connected.
/mcp again. Claude Desktop reconnects automatically.
The very first time it connects, ask Claude to run wf_force_sync, then wf_sync_status. The initial sync pulls your whole outline into the cache — a big board can take a bit, but every read after that is instant. Now ask: "Find my tasks node in Workflowy." If it comes back with a breadcrumb, the engine works.
The server gives Claude ability. Skills give it judgment. A skill is a small Markdown file with a trigger. When your words match the trigger, Claude loads the skill's knowledge before acting — so it picks the right tool, respects your structure, and formats output the way you want.
SKILL.md file. The top (frontmatter) says when to load it. The body is plain instructions Claude reads. No code. Drop it in your skills directory and it's live.
This is the important one. Your MCP server knows the mechanics of Workflowy but nothing about your outline. The system skill supplies the structural knowledge the tools need to operate correctly: what your top-level columns mean, where different kinds of things live, your naming conventions, and which tool to reach for.
--- name: workflowy-system description: >- My Workflowy operating system. Trigger whenever the user asks to do anything IN Workflowy — search, create, move, read — or mentions a column (Tasks, Capture, Reference) or area of their outline. Companion to the Workflowy MCP tools: it gives the structural map those tools need to operate correctly. --- # My Workflowy — Structure ## Top-level columns - **TASKS** — active work. Search here for anything "to do". - **CAPTURE** — inbox / journal. New raw notes land here. - **REFERENCE** — the deep archive (most nodes live here). ## Rules the tools must follow - To LOCATE a node, always use `wf_find_node`. Never dump a tree. - "Search IN/UNDER <section>" → pass that section as the bookmark (WHERE), and the search terms as the query (WHAT). - Create at the top by default. Never add notes to nodes — use child bullets instead. - Return ALL matching results. Don't cap arbitrarily.
That's the whole trick: describe your outline in plain English, name the tools, and state your rules. Claude now behaves like it has worked inside your board for years.
Workflowy imports plain-text outlines by indentation. When you ask Claude to draft something you'll paste into Workflowy, you want perfectly nested bullets — not prose. A format skill guarantees it.
--- name: workflowy-format description: >- Output content as a nested bullet outline ready to paste into Workflowy. Trigger on "Workflowy format", "format for Workflowy", "WF format", or any list/plan/outline meant for pasting in. --- # Workflowy Format Rules - Output ONLY the outline — no preamble, no code fence. - One item per line. Nest with 2 spaces per level. - Parent bullets first, children indented beneath. - No blank lines between bullets (Workflowy reads them as breaks). - Keep each bullet short; push detail to child bullets, not notes.
Drop each skill folder into your skills directory and it's available immediately — no restart, no build.
~/.claude/skills/<skill-name>/SKILL.mdThe description line is the entire firing mechanism. Claude scans descriptions on every message; when your request matches, it loads that skill's body before touching the tools. So write the description in terms of the phrases you actually say — "sort my inbox", "add to my calendar", "what's in my tasks". The more concrete the trigger, the more reliably it fires.
| Symptom | Fix |
|---|---|
| Server won't appear in Claude | Use an absolute path to dist/index.js. Confirm npm run build produced dist/. In Claude Code, re-run /mcp. |
| "No API key configured" | Key missing or misplaced. Set WORKFLOWY_API_KEY in the interface config's env, or fill config.json. |
| Search returns nothing | Cache is empty or still building. Run wf_force_sync, then wf_sync_status to watch node count climb. |
| Garbled / protocol errors | You logged to stdout. Switch every console.log to console.error — stdout is protocol-only. |
| Claude picks the wrong tool | Tighten the skill description with the exact phrases you use, and keep your toolset small — one obvious tool per intent. |
| Rate-limit (429) errors | Throttle API calls (a limiter like bottleneck) and back off on 429. Reads come from cache, so this should be rare. |