Skip to main content
Glama
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.