Skip to main content
Glama
jherr

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.