Fretboard Voicing Explorer MCP Server
by jherr
README.md
# Fretboard Voicing Explorer — MCP Apps dual-host demo
A runnable demo of **MCP Apps portability**: a TanStack Start app hosts its own
HTTP MCP server, consumes that server to render an interactive fretboard
`ui://` widget inline in chat, and the **same** server is consumed by **Goose**
to render the **same** widget in a different host.
> One MCP server. One `ui://` resource. Two hosts.
## What's here
```
src/
mcp/
voicing.ts # pure music model: chord→voicing, voice-leading analysis
voicing.test.ts # vitest unit tests (deterministic)
server.ts # MCP server: show_voicing, evaluate_voicing, analyze_progression
widget/fretboard.html # host-agnostic ui:// widget (self-contained, no app imports)
routes/
fretboard.tsx # chat host UI + <MCPAppResource> render
api.mcp.ts # the Streamable HTTP MCP server endpoint
api.fretboard.chat.ts # chat route: createMCPClient(self) + Anthropic adapter
api.mcp-app.call-tool.ts # bridge call endpoint (allowlisted)
sandbox/sandbox_proxy.html # separate-origin MCP Apps sandbox proxy (served on :3100)
scripts/sandbox-server.mjs # tiny static server for the sandbox proxy
goose/config-snippet.md # how to point Goose at /api/mcp
```
## Prerequisites
- Node 20+, `pnpm`
- `ANTHROPIC_API_KEY` in `.env.local` (the chat route's Anthropic adapter reads it)
- Goose Desktop ≥ v1.19.0 for the cross-host half (verified on v1.39.0)
## Run
```bash
pnpm install
cp .env.example .env # ports: app 4321, sandbox proxy 3100
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env.local
pnpm dev # starts the app (:4321) AND the sandbox proxy (:3100)
```
Open **http://localhost:4321/fretboard**. The app uses a fixed port (`4321`,
set in `vite.config.ts` with `strictPort`) so the origin is stable for Goose;
if it's taken, free it or change the port in `vite.config.ts` + Goose config.
```bash
pnpm test # runs the pure-model unit tests
```
## Architecture
```
Browser (host #1) ──▶ /fretboard chat UI, renders <MCPAppResource>
/api/fretboard/chat createMCPClient(self) + Anthropic
┌── HTTP MCP ──────▶ /api/mcp MCP server (3 tools + ui:// resource)
│ /api/mcp-app/call-tool bridge endpoint (evaluate_voicing only)
│
│ widget iframe ──▶ :3100 sandbox proxy separate origin (security isolation)
│
└── Goose (host #2) ── points at /api/mcp ── renders the SAME ui:// widget
```
The `ui://` widget speaks the **mcp-ui postMessage protocol**; the server wraps
it with the **mcp-apps adapter** (`@mcp-ui/server`) which translates to the MCP
Apps (ext-apps) protocol both hosts speak — so the widget is host-agnostic.
## Resolve-first findings (verified from source)
1. **`ui://` reconciliation is a pull model.** `@tanstack/ai-mcp@0.2.0` reads the
tool's `_meta.ui.resourceUri` at discovery, stamps it, then fetches the
resource via `resources/read` and matches `content.uri === uiResourceUri`. So
`show_voicing` sets `_meta.ui.resourceUri` (and `openai/outputTemplate` for
Goose/Apps SDK) and the server exposes the resource via `resources/read`.
2. **Adapter:** `anthropicText('claude-sonnet-4-6')` from `@tanstack/ai-anthropic`.
3. **Transport:** the MCP SDK's `WebStandardStreamableHTTPServerTransport`
(`Request`→`Response`, stateless per-request) serves `/api/mcp` straight from a
TanStack Start route — no Node bridging.
4. **`@mcp-ui/server@6.1.0`** `wrapHtmlWithAdapters` + `getAdapterMimeType` emit
`text/html;profile=mcp-app`.
5. **Sandbox proxy** is the ext-apps double-iframe proxy, self-hosted on a
separate origin (`:3100`) — `@mcp-ui/client@7.1.1`'s `AppRenderer` requires it.
## Demo script
1. **TanStack app.** `/fretboard` → "Show me a Cmaj7 voicing." The fretboard
renders inline. Drag a note → `evaluate_voicing` → the analysis updates.
"What comes next?" → sends a prompt that advances the progression.
2. **Goose, same server.** Follow `goose/config-snippet.md`, restart Goose, ask
for the same chord. The same widget renders in Goose; the same drag yields the
same `evaluate_voicing` result; narration comes from Goose's own model.
3. **Honest divergence + security.** Same tool result, host-specific narration.
A `javascript:` link is rejected by both hosts' scheme allowlist. A text-only
client still gets a usable voicing via the mandatory text fallback.
## Status
- ✅ One `/api/mcp` server with 3 tools + the `ui://` resource (verified over the wire).
- ✅ TanStack host renders the `ui://` widget cross-origin through the sandbox proxy.
- ✅ `show_voicing` runs, the model narrates the computed voicing; text fallback present.
- ✅ Pure `evaluate_voicing` model is unit-tested and deterministic.
- ✅ Live in-widget host bridge works: the widget is seeded with the computed
voicing (delivered as `ui/notifications/tool-input`), and an in-widget tool call
round-trips `evaluate_voicing` through the bridge endpoint and renders the result.
- ✅ Bridge endpoint enforces an allowlist (`evaluate_voicing` only).
### Isolated widget harness
`/fretboard-test` renders the widget with a hardcoded Cmaj7 seed and the real
bridge — **no chat, no API key** — handy for working on the widget in isolation.
### Notes / gotchas
- The widget HTML is inlined into the server module via Vite `?raw`, so after
editing `src/mcp/widget/fretboard.html` you need a **full dev restart** (HMR
alone serves a stale widget).
- The bridge result comes back double-wrapped (the bridge returns a full
`CallToolResult` and the host wraps it again); the widget unwraps to the
analysis. See `structuredFrom` in the widget.
---
This app was scaffolded with TanStack Start (file-based routing, Nitro server,
Tailwind). See https://tanstack.com/start for framework docs.
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues