planb-mcp
v0.0.0-alpha.0
Published
Local stdio MCP server that opens a browser form so the user can answer questions, via URL-mode elicitation.
Readme
A local stdio MCP server for planning sessions. Its centerpiece is
ask_user, which opens a modern browser form for the user to answer one or more
questions and returns their answers to the calling agent. The form is laid out
as a tab panel — an optional Markdown intro tab plus author-defined sections
— and each option can carry its own Markdown illustration that renders in a side
panel as the user moves through the choices.
Beyond asking, planb-mcp persists the planning session. A session ties together
(a) one or more ask_user questioning rounds and (b) one or more saved plan
versions under a single session id, so the whole arc — what was asked, what the
user answered, and how the plan evolved — can be replayed later by a future
retrospective app. Three tools cooperate around the session id: you call
init_planb_session once to mint it, then pass that id to every ask_user and
save_plan call.
The browser is opened in one of two ways, chosen automatically per client:
- URL-mode elicitation (MCP spec
2025-11-25, SEP-1036) when the client advertisescapabilities.elicitation.url(e.g. Claude Desktop / claude.ai/code). - Direct open otherwise: since this server runs locally, it opens your
default browser itself. This covers the Claude Code CLI, which advertises
a bare
elicitation: {}with nourlsub-capability.
Either way, the user's answers do not flow back through the agent — they are
submitted directly to a local HTTP callback server that this MCP server runs on
an ephemeral 127.0.0.1 port.
⚠️ Non-sensitive input only. This UI is for preferences, creative answers, and choices. Do not collect passwords, API keys, tokens, or payment data.
Requirements
- Node.js 18+
- Any MCP client. Tested with the Claude Code CLI 2.1.159 (uses the direct-open path) and works with URL-elicitation-capable clients (Claude Desktop / claude.ai/code).
Client elicitation support
| Client | advertises elicitation.url? | How the browser opens |
|--------|-------------------------------|------------------------|
| Claude Code CLI (2.1.x) | No (sends bare elicitation: {}) | Server opens it directly |
| Claude Desktop / claude.ai/code | Yes | Via URL-mode elicitation |
Install
npm installRun
The package ships a single executable, planb-mcp, that boots the MCP
stdio server (it loads the TypeScript through tsx in-process — no build step).
It writes nothing to stdout (reserved for JSON-RPC); all logs go to stderr.
You normally don't run it by hand — Claude Code launches it once registered
(below). Equivalent ways to start it:
planb-mcp # if globally installed / linked (see "Make `planb-mcp` global")
npx planb-mcp # after publishing, or from a global link
npx . # from this project directory, no install needed
npm start # from this project directory
npm run serve # legacy: tsx projects/mcp/server.ts directlyMake planb-mcp global
npx planb-mcp by name resolves either from the npm registry (after
publishing) or from a global link. This package is private: true, so the
no-publish route is a global link/install from the project directory:
npm link # symlinks `planb-mcp` onto your PATH (reversible: npm unlink -g planb-mcp)
# or:
npm install -g . # installs it globally from sourceAfter that, planb-mcp and npx planb-mcp work from anywhere.
(To publish to the registry instead, remove "private": true from
package.json and run npm publish.)
Register in Claude Code (local stdio)
The three tools (init_planb_session, ask_user, save_plan) should appear
in the tool list after registering (restart Claude Code or reconnect MCP
servers; verify with claude mcp list).
Option A — global command (simplest, after npm link/global install)
claude mcp add planb-mcp -- planb-mcpOption B — absolute path to the bin (no global install needed)
The bin is a self-contained executable (shebang + tsx loader), and the server
resolves its public/ assets relative to its own file, so this works regardless
of launch directory. Replace /ABS/PATH with this project's absolute location
(e.g. /ABS/PATH/TO/planb-mcp).
claude mcp add planb-mcp -- /ABS/PATH/bin/planb-mcp.mjsOr as JSON in ~/.claude.json (user scope) or a project-local .mcp.json:
{
"mcpServers": {
"planb-mcp": {
"command": "/ABS/PATH/bin/planb-mcp.mjs"
}
}
}Tools
planb-mcp exposes three tools. The session id minted by init_planb_session is
the thread that ties a questioning-and-planning arc together; pass it to every
ask_user and save_plan call.
| Tool | Args | Returns |
|------|------|---------|
| init_planb_session | title?: string, intent?: string (both optional) | Text sessionId: <uuid> followed by a short usage note. Creates the session on disk. No browser. |
| ask_user | sessionId: string (UUID, required), title, introTitle?, intro?, questions[] | Text holding the answers JSON { [questionId]: answerValue } (or a cancel/timeout message). Opens the browser form. Persists the question payload and the outcome under the session. |
| save_plan | sessionId: string (UUID, required), plan: string (Markdown, required), title?: string | Text Saved plan v<N> for session <id> plus the absolute file path. Persists the plan as a new version each call (history kept). No browser. |
Usage flow
- Call
init_planb_sessiononce — note itssessionId. - Pass that
sessionIdto everyask_userround (gather requirements) and everysave_plan(snapshot the plan). - Each
save_planwrites a new version, so the full plan history is preserved under the session.
If ask_user or save_plan is given a sessionId that was never created, it
returns a message telling the agent to call init_planb_session first.
The ask_user tool
| | |
|---|---|
| Name | ask_user |
| Title | Ask the user (browser UI) |
| Description | Opens a browser form so the user can answer one or more questions, and returns their answers. |
Requires a
sessionId. Everyask_usercall now takes the UUID returned byinit_planb_session(in addition to the fields below). The id is used only to persist the ask under the session — the browser form never sees it.
Input schema
{
sessionId: string, // UUID from init_planb_session (required)
title: string, // heading at the top of the form (plain text)
introTitle?: string, // optional concise title for the intro tab (default "Overview")
intro?: string, // optional intro — GitHub-flavored Markdown (see below)
questions: Question[] // 1..20 items
}Tabs & navigation
The form is a tab panel. If intro is provided it becomes the first tab.
Questions are then grouped into tabs by their optional tab field (questions
that share a tab value land in the same tab; tab order follows first
appearance; untagged questions fall into a single "Questions" tab). When there
is only one tab, the tab strip is hidden.
- Left / Right arrows switch tabs (the tab actually changes — automatic activation), as does clicking a tab; Home / End jump to the first / last.
- Up / Down arrows move between a question's options; Enter / Space select (single) or toggle (multi).
- Text fields and sliders keep their native arrow behavior.
- A footer carries ‹ Back / Next › and, on the final tab, Submit
(enabled once every
requiredquestion is answered). Tabs that still contain an unanswered required question show a small dot.
Markdown in intro and option illustrations
intro is rendered as GitHub-flavored Markdown in the browser form:
headings, lists, tables, links, blockquotes, syntax-highlighted code blocks
(js, python, ts, tsx via highlight.js), and diagrams. Diagrams
can be either Mermaid (mermaid) for flowcharts/sequences/graphs, or a
plain-text/ASCII sketch in a text block when a quick hand-drawn layout reads
more clearly — the choice is left to whoever authors the intro. The markdown
HTML is sanitized with DOMPurify before rendering. Rendered Mermaid diagrams are
clickable — they open in a large lightbox modal (close with the × button, a
backdrop click, or Esc).
The same renderer powers per-option illustrations: for single/multi
questions, an option may be { value, markdown } instead of a plain string. When
any option in a tab has markdown, that tab gains a side panel that shows
the focused option's illustration, updating live as the user moves between
options. Tabs with no option illustrations stay single-column.
The renderer (marked, mermaid, highlight.js, dompurify) is loaded from a
CDN via an import map in public/index.html. If the CDN is unreachable, the
markdown gracefully falls back to plain text and the rest of the form still
works. title and question labels remain plain text.
Question kinds
| kind | UI control | Answer value type |
|------------|-----------------------|-------------------|
| text | single-line input | string |
| longtext | multi-line textarea | string |
| single | radio group | string |
| multi | checkbox group | string[] |
| scale | range slider | number |
type Option = string | { value: string; markdown?: string };
type Question = { id: string; label: string; required?: boolean; tab?: string } & (
| { kind: "text"; placeholder?: string }
| { kind: "longtext"; placeholder?: string }
| { kind: "single"; options: Option[] }
| { kind: "multi"; options: Option[] }
| { kind: "scale"; min: number; max: number; step?: number }
);idis unique within the spec and is the key in the returned answers object.requiredis enforced client-side before submit is allowed.tab(optional) groups the question into a named section/tab. Keep it concise.- For
single/multi, each option is a plain string or{ value, markdown }. The answer is always the option'svalue;markdown(optional) renders in the tab's side panel when the option is focused. Optionvalues must be unique within a question.
Return value
Text content holding a JSON string of { [questionId]: answerValue }.
Other outcomes
- Unknown
sessionId(never created) → a message telling the agent to callinit_planb_sessionfirst; no browser opens. - Declined / cancelled elicitation (URL-elicitation path only) →
"User cancelled." - No submission within 24 hours → a timeout message; the process stays
healthy. (Override the window with the
ASK_ANSWER_TIMEOUT_MSenv var.)
Environment variables
ASK_ANSWER_TIMEOUT_MS— answer timeout in ms (default86400000, i.e. 24h).ASK_NO_OPEN=1— on the direct-open path, log the URL instead of launching a browser (for tests / headless environments).PLANB_MCP_DATA_DIR— override the storage root (default~/.planb-mcp); see Storage.
Example invocation (covers all five kinds, tabs, and an option illustration)
{
"name": "ask_user",
"arguments": {
"title": "Project kickoff preferences",
"introTitle": "Kickoff",
"intro": "A few quick questions to set up your workspace.",
"questions": [
{ "id": "project_name", "kind": "text", "label": "Project name", "placeholder": "e.g. Aurora", "required": true, "tab": "Workspace" },
{ "id": "summary", "kind": "longtext", "label": "One-paragraph summary", "placeholder": "What are we building?", "tab": "Workspace" },
{ "id": "language", "kind": "single", "label": "Primary language", "required": true, "tab": "Workspace",
"options": [
{ "value": "TypeScript", "markdown": "### TypeScript\n\nFull-stack web with strong typing." },
"Go", "Rust", "Python"
] },
{ "id": "integrations", "kind": "multi", "label": "Integrations to enable", "options": ["GitHub", "Slack", "Linear", "PagerDuty"], "tab": "Integrations" },
{ "id": "priority", "kind": "scale", "label": "Priority (1 = low, 5 = high)", "min": 1, "max": 5, "step": 1, "required": true, "tab": "Priority" }
]
}
}A possible returned result:
{
"project_name": "Aurora",
"summary": "A realtime collaboration tool.",
"language": "TypeScript",
"integrations": ["GitHub", "Slack"],
"priority": 4
}The init_planb_session tool
| | |
|---|---|
| Name | init_planb_session |
| Description | Mint a session id and create the session on disk. Call this once at the start of a planning arc. |
Input schema
{
title?: string, // optional short title for the session
intent?: string, // optional one-line description of what you're planning
}Return value
Text content whose first line is sessionId: <uuid>, followed by a short note
to pass that id to subsequent ask_user / save_plan calls. Creating the
session writes <root>/sessions/<id>/session.json (see Storage).
The save_plan tool
| | |
|---|---|
| Name | save_plan |
| Description | Persist a plan (Markdown) under a session as a new version. History is kept. No browser. |
Input schema
{
sessionId: string, // UUID from init_planb_session (required)
plan: string, // the full plan as a Markdown string (required)
title?: string, // optional title for this plan version
}Return value
Text content:
Saved plan v<N> for session <id>
path: <absolute path to plans/v<N>.md>Each call allocates the next version (v1, v2, …) — earlier versions are kept
on disk so the plan's evolution can be replayed. An unknown sessionId returns
a message telling the agent to call init_planb_session first.
Storage
planb-mcp persists each session under a data root — $PLANB_MCP_DATA_DIR if set,
otherwise ~/.planb-mcp:
~/.planb-mcp/
sessions/
<sessionId>/
session.json # manifest: title?, intent?, timestamps,
# latestPlanVersion, planCount, askCount,
# and asks[]/plans[] index entries
asks/
<utcTs>-<askId>.json # one per ask_user call: the full question
# spec + status (pending|answered|timeout|
# declined|error) + answers/outcome
plans/
v1.md v1.json # plan markdown + metadata sidecar
v2.md v2.json # (version, title?, createdAt, file, bytes)
...- The session id is the primary key tying every ask and plan version
together, so the bundled retrospective viewer (
planb-retro, see below) can read this layout and replay the whole planning arc. - Set
PLANB_MCP_DATA_DIRto relocate the root (handy for tests — point it at a temp dir). - Persistence is best-effort: a disk failure is logged to stderr but never blocks an answer or crashes the server.
Retrospective viewer (planb-retro)
The package ships a second executable, planb-retro, a small read-only
web app that browses everything planb-mcp has persisted. It reads the same
data root ($PLANB_MCP_DATA_DIR or ~/.planb-mcp) and renders, per session, the
questions that were asked, the answers the user gave, and every saved plan
version — so you can replay how a planning arc unfolded.
planb-retro # if globally installed / linked
npx planb-retro # after publishing, or from a global link
npm run retro # from this project directoryIt starts an Express server on http://127.0.0.1:4317 by default (override with
the PORT env var). It only reads the data root — it never modifies a
session or opens the form.
The /planb skill
A user-invoked /planb skill (skills/planb/SKILL.md) drives a full
planning effort through the three tools above, so the whole arc is tied together
under one session id and persisted for later review. It requires the planb-mcp
MCP server to be connected (see Register in Claude Code).
How it works
- On the first
ask_usercall, a lazyexpresscallback server binds an ephemeral port on127.0.0.1(port0→ OS-assigned). One instance per process, reused thereafter. - The tool generates a
sid, stores the question spec under it, registers a pending resolver, and buildshttp://127.0.0.1:<port>/ask?sid=<sid>. - It opens that URL in the browser:
- if the client advertises
elicitation.url, via a URL-mode elicitation (the client opens it and the server later sendsnotifications/elicitation/complete); - otherwise the server opens the default browser directly.
- if the client advertises
- The browser page fetches
GET /spec?sid=..., renders the questions, enforcesrequired, and on submitPOSTs{ sid, answers }to/submit. /submitresolves the pending promise and the tool returns the answers as JSON. The answer timeout (24h by default) guards against no submission.
File structure
planb-mcp/
package.json # "type": "module"; "bin": planb-mcp + planb-retro; scripts
tsconfig.json
LICENSE
bin/
planb-mcp.mjs # MCP server entry point (loads projects/mcp/server.ts via tsx)
planb-retro.mjs # retrospective viewer entry (loads projects/plans-retro/src/server.ts)
skills/
planb/SKILL.md # the user-invoked /planb planning workflow
projects/
mcp/
server.ts # MCP stdio server: init_planb_session, ask_user, save_plan + lazy callback server
tool-schemas.ts # zod input shapes for the three tools (sessionId, save_plan, ...)
store/
index.ts # public entry: re-exports the session store API + types
store.ts # session store API (createSession, appendPendingAsk, appendPlanVersion, ...)
types.ts # on-disk JSON types (SessionRecord, AskRecord, PlanMeta, ...)
paths.ts # path helpers for the sessions/<id>/ layout
fs-utils.ts # atomic JSON/text writes
lock.ts # per-session write lock
ask-user-app/
src/
callback-server.ts # ephemeral-port express server: /ask, /spec, /submit
open-browser.ts # opens the default browser (MCP fallback + open-app dev script)
questions.ts # Question type + tool input schema (zod)
public/
index.html # UI shell: import map (React, htm, Tailwind, md libs) + mount node
app.js # entry: loads Tailwind, mounts the React app into #root
components.js # React form: App, tab strip/panels, question fields, footer, state screens
intro.js # async Markdown renderer (intro tab + option panel) + diagram lightbox (React)
markdown.js # marked/DOMPurify/highlight.js/mermaid glue
validation.js # required-field logic (pure)
theme.js # light/dark + palette controls, persisted to localStorage
styles.css # theme tokens + markdown/diagram/loading styles (Tailwind-uncovered)
logo.svg # shared brand mark (CSS-masked to the active accent)
favicon.svg # theme-aware favicon variant of the logo
examples/ # sample AskUserInput configs for the open-app dev launcher
light.json
medium.json
heavy.mjs
plans-retro/ # retrospective viewer (planb-retro): read-only web app
src/
server.ts # Express SSR server: lists sessions, renders asks + plan versions
views/ # EJS templates (index, session, questionnaire, plan, partials)
public/ # retro.css + client JS (serves ask-user-app/public under /ui)
scripts/
open-app.ts # dev launcher: render the UI from an examples/ config (npm run open-app)
planb-mcp-smoke.ts # end-to-end smoke test: drives the real server over stdio (npx tsx scripts/planb-mcp-smoke.ts)Security notes
- Callback server binds
127.0.0.1only, on an ephemeral port. - No auth beyond the unguessable
sid(session maps are in-memory only). - The UI sets no cookies and stores no answers locally — the only thing it keeps
in
localStorageis a non-sensitive theme/palette preference. It is built with React + htm + Tailwind, loaded as native ES modules from a CDN (esm.sh) via the import map inindex.html— same mechanism as the Markdown/diagram renderer, and still with no bundler or build step. Answers are POSTed only to the local callback server. - stdout is never written to — a single stray write would corrupt the JSON-RPC stream.
License
Released under the MIT License — © 2026 Ramy Ben Aroya.
