Skip to main content
Glama

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:

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.

Related MCP server: Basic MCP Server

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):

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):

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 at it to poke around without a real host:

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:

{
  "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.

F
license - not found
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/vishaldodiya/mcp-ui'

If you have feedback or need assistance with the MCP directory API, please join our Discord server