Serve Fizzy over MCP with fizzy mcp

Merged
JE
2mo ago
+4057-12

What

fizzy mcp runs an MCP (Model Context Protocol) server on stdin/stdout, serving Fizzy as tools backed by the signed-in account. Eight domain gateway tools — fizzy_identity, fizzy_boards, fizzy_columns, fizzy_cards, fizzy_comments, fizzy_steps, fizzy_tags, fizzy_users (45 actions) — served through the shared toolkit at github.com/basecamp/mcp, dispatching real API calls through the CLI's authenticated, account-scoped fizzy-sdk client. One install, no separate binary:

claude mcp add fizzy -- fizzy mcp
fizzy mcp --writes                 # serve write actions too (read-only is the default)
fizzy mcp --domains boards,cards   # narrow the served surface (fails closed on unknown keys)

Same pattern as basecamp/hey-cli#357, adapted to Fizzy.

Design

Why not import fizzy-mcp-server? This repo is public; basecamp/fizzy-mcp-server is private, so a module dependency is a broken build for everyone outside the org, and vendoring it wholesale would publish it. Everything the CLI actually needs is public — the basecamp/mcp toolkit (gateway, mcptest) and the fizzy-sdk client already linked in — except the catalog, which is small, hand-written, and deliberately duplicated per the toolkit's two-instance-by-duplication convention: internal/mcpserver/catalog/ carries a verbatim copy of the server's internal/catalog (catalog.go, domains.go, and the rendered-surface snapshot), synced by scripts/sync-mcp-catalog.sh with provenance recorded in PROVENANCE.json. Machinery proven in both instances moves to the toolkit; until then drift shows up as a reviewed diff here, and the snapshot test renders the full served surface so any sync shows its effect.

Dispatch through the SDK, not a second HTTP client. Where fizzy-mcp-server carries its own minimal API client, the CLI already holds an authenticated *fizzy.Client/*fizzy.AccountClient — token resolution, profiles, retry, account scoping. The dispatcher (internal/mcpserver/dispatch.go) maps catalog operations onto the SDK's generic verbs: path params substituted and escaped, query params encoded Rails-style (board_ids[]=a&board_ids[]=b), remaining params gathered into the body and wrapped under the operation's body key, with unknown params rejected in-band naming what the action accepts. Response conventions match the server's: paginated listings surface the Link rel="next" page number as {"data": ..., "next_page": N} to pass back as page; a bodiless 201's Location is followed so create actions answer with the created resource; API errors come back in-band with their HTTP status.

Account scoping diverges deliberately. fizzy-mcp-server discovers the account slug via /my/identity when FIZZY_ACCOUNT is unset; the CLI already has profile/account resolution (fizzy setup, --profile, FIZZY_PROFILE), so fizzy mcp requires a configured account like every other account-scoped command and never invents a second resolution path. get_identity stays unscoped for slug discovery from the tool surface itself.

Read-only by default, matching the server's posture. --writes opts in, paired with a Read+Write access token — the token's permission is the server-side enforcement, this filter is the client-side surface. --domains narrows the served tools and fails closed on unknown keys at startup.

Tests

  • Catalog pins (catalog/catalog_test.go): 8 domains/45 actions, curation validation table, provenance, and the full rendered-surface snapshot (-update to regenerate).
  • Request-builder tables (dispatch_test.go): path/query/body routing, Rails-style encoding, error cases, Link-header parsing.
  • Wire tests (server_test.go): a real MCP client over the toolkit's mcptest harness, dispatching through a real fizzy-sdk client onto a fake Fizzy (httptest) that asserts bearer auth, account scoping, query encoding, and body wrapping, and plays back 204s, 201 Locations, and Link pagination.
  • Command-level end-to-end (mcp_test.go): fizzy mcp through the cobra root with an in-memory transport seam — initialize handshake, tools/list, and a tool call asserting the CLI's own bearer token on the upstream request; read-only default, --writes opt-in, --domains passthrough failing closed.

make check (fmt, vet, golangci-lint v2.10, tidy, race tests) green; SURFACE.txt regenerated.


Summary by cubic

Adds fizzy mcp to serve Fizzy over the Model Context Protocol on stdio from the existing CLI, no separate binary: 8 domain tools covering 45 actions (boards, cards, comments, steps, tags, users, columns, identity), dispatched through the CLI's signed-in, account-scoped SDK client instead of a second HTTP client. Read-only by default; --writes opts in to write actions and --domains narrows the served tools, failing closed on unknown keys.

Design notes

  • Reuses the basecamp/mcp toolkit (gateway, mcptest); new deps are github.com/basecamp/mcp and github.com/modelcontextprotocol/go-sdk.
  • The tool catalog is deliberately duplicated from fizzy-mcp-server instead of imported or vendored: that repo is private, so a module dependency would break every outside build. A sync script copies it verbatim with provenance recorded, and a snapshot test renders the full surface so any drift shows up as a reviewable diff.
  • Dispatches real API calls through fizzy.Client/fizzy.AccountClient, so auth, retry, profile, and account scoping already work; account resolution follows the CLI's existing path (configured account, get_identity unscoped for slug discovery) rather than the server's /my/identity fallback. The identity tool's describe doc stays neutral about resolution, and the README and --help enumerate all eight domains.
  • Response conventions match fizzy-mcp-server: boards, tags, and users listings paginate via the Link rel="next" header and accept the page param (columns and steps render full collections and stay unpaginated), a bodiless 201 Location is followed so create actions answer with the created resource, and API errors come back in-band with their HTTP status.