mcp-todo-demo
by vishaldodiya
README.md
# mcp-todo-demo
A minimal MCP server that shows an interactive to-do list (checkboxes) as
in-chat UI, for teaching the basics of mcp-ui / MCP Apps:
- `widget/` - the actual UI: a real standalone React app (a normal Vite +
React project), built to a single self-contained `widget/dist/index.html`.
- The widget is registered as its own MCP resource (`registerAppResource`,
URI `ui://todo-list/view.html`) - per the **MCP Apps spec**
(`@modelcontextprotocol/ext-apps`), the UI is a separate resource that
tools *link to*, not something a tool embeds in its own result. `src/server.ts`
uses `createUIResource`'s `adapters.mcpApps` option to inject a small
runtime script translating the widget's plain `postMessage` calls into
whatever wire format the host's MCP Apps implementation expects.
- `list_todos` (`registerAppTool`) - links to that resource via
`_meta.ui.resourceUri`, so the host renders it as an interactive checkbox UI.
- `toggle_todo` - called automatically when the user checks/unchecks an item.
The checkbox's `onChange` does `window.parent.postMessage({ type: 'tool', ... })`,
the host turns that into a real tool call, and the result goes back into
the conversation - that's how the LLM "knows" what's done. It's a normal,
model-visible tool (not app-only), so its result - an encouraging
confirmation, e.g. `"Buy milk" is done! 2 to go - next up: "Walk the dog"`
- can surface as a real chat reply on its own. (An earlier version of this
demo also sent a `{type:'prompt', payload:{prompt}}` action to force a
chat reply, but that always renders as a fake `role: 'user'` message in
the host - removed once `toggle_todo` itself could produce a visible reply.)
- **The host reuses the same mounted widget panel** rather than recreating
it from scratch on every linked tool call - it doesn't re-run our
bundle's initial `window.__TODOS__` injection a second time. Instead, it
pushes each tool's result into the already-open iframe as a
`ui-lifecycle-iframe-render-data` message (`payload.renderData.toolOutput`).
Both `list_todos` and `toggle_todo` include the current state as a JSON
content block for exactly this reason, and `TodoList.tsx` listens for that
message and updates its own state from it (see `extractTodos` /
the `useEffect` in `widget/src/TodoList.tsx`) - without this, the visible
checkbox panel would silently go stale even though the server-side state
(and the model's text replies) are correct.
## Setup
`widget/` is a pnpm workspace member (see `pnpm-workspace.yaml`), so one
install at the root covers both packages:
```bash
pnpm install # installs deps for both root and widget/
pnpm run build:widget # builds widget/dist/index.html
pnpm run dev # starts the server over stdio
```
Re-run `pnpm run build:widget` whenever you edit `widget/src/TodoList.tsx`.
You can also run `pnpm --dir widget run dev` to iterate on the widget in a
normal browser tab before wiring it back into the MCP server.
This repo uses pnpm (there's a `pnpm-lock.yaml`) - don't mix in `npm install`,
it'll create a second, conflicting lockfile.
## Two ways to run it
The tools themselves (`src/server.ts`) don't know or care which transport
serves them - that's a deliberate teaching point. `index.ts` and `http.ts`
are both thin wrappers around the same `createServer()`.
**stdio** (what Claude Desktop / Claude Code expect for local servers):
```bash
pnpm run dev
```
**HTTP** (exposes `POST /mcp`, for curl, the MCP Inspector, or any host that
takes a server URL instead of a local command):
```bash
pnpm run dev:http
# MCP server listening at http://localhost:3000/mcp
```
It runs in stateless mode (no session IDs to manage) - every request gets
its own server+transport pair, but the to-do data still persists between
requests because it lives in `store.ts` at module scope, not on the
transport. Point the [MCP Inspector](https://github.com/modelcontextprotocol/inspector)
at it to poke around without a real host:
```bash
npx @modelcontextprotocol/inspector
# then connect to http://localhost:3000/mcp (Streamable HTTP)
```
## Wiring it into Claude Desktop
Add to Claude Desktop's MCP config (Settings -> Developer -> Edit Config),
using the absolute path to this folder:
```json
{
"mcpServers": {
"todo-demo": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/mcp-todo-demo/src/index.ts"]
}
}
}
```
Restart Claude Desktop, then in a chat: "show my to-do list". Claude calls
`list_todos`, renders the checkbox UI in-chat, and clicking a checkbox round-trips
through `toggle_todo`.
## Wiring it into claude.ai (remote connector)
claude.ai's web UI connects to remote MCP servers over HTTP, not stdio, so
this needs `pnpm run dev:http` plus a public URL pointing at your localhost
(e.g. a pinggy/ngrok tunnel to `http://localhost:3000/mcp`). Add it in
claude.ai under Settings -> Connectors -> Add custom connector, using the
tunnel's `/mcp` URL.
> **Why the `registerAppTool`/`registerAppResource` rewrite was necessary:**
> the older mcp-ui convention (a tool embedding `content: [uiResource]`
> directly in its own result) is *not* what Claude's MCP Apps support
> expects - it only renders UI for tools whose `_meta.ui.resourceUri` points
> at a separately-registered resource, which is what `src/server.ts` does
> now. If you still see text but no checkbox UI after this change, double
> check `pnpm install` actually pulled in `@modelcontextprotocol/ext-apps`
> and that you restarted the server/tunnel after the change.
## Suggested live-coding order
1. `store.ts` - just the in-memory array, explain it's a stand-in for a DB.
2. `index.ts` skeleton - `McpServer` + `StdioServerTransport`, no tools yet.
3. Add `list_todos` as a plain `server.registerTool` returning **only** a
text block - show it works as a normal MCP tool first, no UI involved.
4. Open `widget/`, show it's a normal Vite + React app
(`pnpm --dir widget run dev` renders `TodoList.tsx` in a plain browser
tab, no MCP involved yet).
5. `pnpm run build:widget`. Introduce `registerAppResource` (from
`@modelcontextprotocol/ext-apps/server`) to register the built
`widget/dist/index.html` (via `src/ui-render.ts` + `createUIResource`) as
its own resource at `ui://todo-list/view.html` - this is the MCP Apps
tool/UI split: the UI is a resource, not something a tool returns inline.
6. Switch `list_todos` to `registerAppTool` with
`_meta: { ui: { resourceUri: 'ui://todo-list/view.html' } }` - now the
checkbox list renders in chat, but clicking does nothing yet.
7. Add the `postMessage({ type: 'tool', payload: { toolName, params } })` call
inside `TodoList.tsx`'s checkbox `onChange` - this is the "UI -> LLM" half.
8. Add `toggle_todo` as a second `registerAppTool` - this is the "LLM -> UI"
half: a normal tool the host calls on your behalf, whose result lands
back in the conversation and the linked resource re-renders. Make its
return text an encouraging confirmation (`encouragement()` in
`src/server.ts`) rather than a flat "done" message, since this is what
actually shows up as the chat reply.
9. Point out that the widget panel doesn't refresh on its own after a
linked tool call - the host pushes the result in as `ui-lifecycle-iframe-render-data`
instead of remounting the iframe. Add the `useEffect`/`extractTodos`
listener in `TodoList.tsx` so the already-open panel picks up the change.
10. Bonus: `src/http.ts` - same `createServer()`, different transport
(`StreamableHTTPServerTransport` behind an Express `/mcp` route instead of
stdio) - shows the tool/UI code is transport-agnostic.
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues