Serve Basecamp over MCP with basecamp mcp - #662
Conversation
Run an MCP server on stdin/stdout serving Basecamp as domain gateway tools backed by the signed-in account: fifteen tools covering 247 operations, derived from basecamp-sdk's model exports via the shared toolkit at github.com/basecamp/mcp and dispatched through the CLI's authenticated, account-scoped SDK client. The vendored model snapshot under internal/mcpserver/model/ tracks the basecamp-sdk version go.mod pins, synced by scripts/sync-mcp-model.sh with provenance recorded. The sync patches tags onto the operations the export leaves untagged and drops the three raw-binary uploads that cannot ride the JSON tool-call convention; both tables live in the script and are pinned by tests. Full surface by default, matching basecamp-mcp-server's posture; --read-only narrows to read-only actions and --domains narrows the served domains, failing closed on unknown keys.
go.mod grew github.com/basecamp/mcp and github.com/modelcontextprotocol/go-sdk; the corrected hash comes from the nix-build check, which reports it exactly for this purpose.
There was a problem hiding this comment.
Pull request overview
Adds basecamp mcp, exposing the authenticated Basecamp SDK through an account-scoped MCP stdio server.
Changes:
- Adds 15 domain gateway tools with filtering and read-only mode.
- Derives and dispatches 247 SDK-backed actions from vendored model snapshots.
- Adds command, wire, catalog, request-building, and surface tests.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Reviewed changes
Copilot reviewed 20 out of 23 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
.surface |
Records the MCP command surface. |
README.md |
Documents MCP setup and options. |
e2e/smoke/smoke_lifecycle.bats |
Marks MCP smoke testing out of scope. |
go.mod |
Adds MCP dependencies. |
go.sum |
Records dependency checksums. |
internal/cli/root.go |
Registers the MCP command. |
internal/commands/commands.go |
Adds MCP to the command catalog. |
internal/commands/commands_test.go |
Registers MCP in catalog tests. |
internal/commands/mcp.go |
Implements the MCP command. |
internal/commands/mcp_test.go |
Tests command-level MCP behavior. |
internal/mcpserver/catalog.go |
Loads and rescopes the SDK catalog. |
internal/mcpserver/catalog_test.go |
Verifies catalog completeness and provenance. |
internal/mcpserver/dispatch.go |
Builds and dispatches API requests. |
internal/mcpserver/dispatch_test.go |
Tests request construction. |
internal/mcpserver/domains.go |
Defines domain curation. |
internal/mcpserver/model/PROVENANCE.json |
Records model provenance. |
internal/mcpserver/model/behavior-model.json |
Vendors operation traits. |
internal/mcpserver/model/openapi.json |
Vendors API schemas and routes. |
internal/mcpserver/server.go |
Builds the MCP gateway server. |
internal/mcpserver/server_test.go |
Tests MCP wire behavior. |
internal/mcpserver/testdata/catalog_snapshot.txt |
Pins the exposed tool surface. |
scripts/sync-mcp-model.sh |
Synchronizes and patches model snapshots. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a31c2045d0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 23 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
internal/mcpserver/dispatch.go:77
nextPagereturns the query value as a string, so this emits"next_page":"2", while every advertisedpageparameter is an integer and the documented wrapper uses a numericN. Returning an integer keeps the continuation value consistent with the action schema and avoids clients copying a schema-invalid string into the next call.
wrapped, err := json.Marshal(map[string]any{"next_page": next, "results": resp.Data})
internal/mcpserver/dispatch.go:29
- These generic
AccountClientrequest methods bypass the SDK's semantic operation path. As a result, theGatingHooksinstalled byappctxnever run, so this long-running server is not protected by the CLI's rate limiter, bulkhead, or circuit breaker; generic mutations also miss the generated per-operation idempotent retry policy. Please dispatch through an operation-aware SDK entry point (adding one upstream if necessary) and pass the catalog operation ID/traits so the resilience behavior claimed for this command actually applies.
Get(ctx context.Context, path string) (*basecamp.Response, error)
Post(ctx context.Context, path string, body any) (*basecamp.Response, error)
Put(ctx context.Context, path string, body any) (*basecamp.Response, error)
Delete(ctx context.Context, path string) (*basecamp.Response, error)
* Synthesize the page parameter paginated operations leave undeclared
The SDK export marks six operations paginated — ListWebhooks,
ListChatbots, ListMessageTypes, ListPingablePeople,
ListQuestionAnswerers, ListUploadVersions — without declaring a page
query parameter. The dispatcher rejects parameters an operation does not
declare, so the next_page value those listings return could never be
passed back: every page after the first was unreachable over MCP.
Synthesize the parameter at catalog load from the paginated trait, next
to the account rescope. Trait-driven rather than a name table: it covers
whatever the model marks paginated and no-ops once the export declares
the parameter itself. Pinned by a catalog test asserting every paginated
operation declares exactly one integer page query parameter.
* Surface next_page as a number, matching the page parameter schema
Every advertised page parameter is an integer, and the documented
pagination wrapper is {"next_page": N, "results": ...} — but nextPage
returned the Link header's query value as a string, emitting
"next_page":"2". Clients copying that continuation value into the next
call would send a schema-invalid string. Parse the page number when
extracting it, treating a non-numeric value as no next page, the same as
geared_pagination treats pages.
The new round-trip test drives list_webhooks — one of the operations
whose page parameter is synthesized — through a full pagination cycle:
the next_page a listing returns is accepted as the follow-up call's page
parameter.
* Keep basecamp mcp errors off the MCP wire
Errors returned from the mcp command's RunE — unauthenticated launch,
missing account, unknown domain, transport failure, session errors —
flowed through cli.Execute's error rendering, whose writers all target
stdout. For this command stdout is the MCP JSON-RPC transport, so the
CLI error envelope landed as a malformed protocol message and the real
failure hid behind the client's parse error.
Mark the command stdout_wire, following the annotation convention, and
have Execute report errors for wire commands on stderr: plain lines an
MCP client's stderr log shows as-is, the structured error's hint when
the message does not already carry it, and the same exit code the
envelope path produces. Message and hint can carry SDK- or
transport-controlled text, so both are sanitized to single
terminal-safe lines, the same treatment the styled error renderer
applies.
What
basecamp mcpruns an MCP (Model Context Protocol) server on stdin/stdout, serving Basecamp as tools backed by the signed-in account. Fifteen domain gateway tools —basecamp_projects,basecamp_todos,basecamp_cards,basecamp_messages,basecamp_campfires,basecamp_boosts,basecamp_schedules,basecamp_files,basecamp_people,basecamp_automation,basecamp_reports,basecamp_everything,basecamp_clientside,basecamp_forwards,basecamp_account(247 actions) — derived from basecamp-sdk's model exports via the shared toolkit at github.com/basecamp/mcp, dispatching real API calls through the CLI's authenticated, account-scoped SDK client. One install, no separate binary:Completes the CLI-subcommand trio with basecamp/hey-cli#357 and basecamp/fizzy-cli#208, same uniform shape.
Design
Why not import basecamp-mcp-server? This repo is public;
basecamp/basecamp-mcp-serveris private, so a module dependency is a broken build for everyone outside the org, and vendoring its ~21k-line hand-written tool package would publish it wholesale. Everything the CLI actually needs is public — thebasecamp/mcptoolkit (catalog, gateway, mcptest) and basecamp-sdk's model exports (behavior-model.json+openapi.json, build products of its Smithy model) — so the CLI derives the catalog from those, the way hey-cli does. The domain curation carries over basecamp-mcp-server's grouping (projects, todos, cards, messages, campfires, schedules, files, people, account) where the SDK's tags allow it.Born on the toolkit convention. basecamp-mcp-server's production wire still speaks the ancestor dialect (
{resource, action}calls, centralized describe); converging it is a wire change under decision on the MCP program board ("Basecamp MCP: converge onto toolkit"). This subcommand is new surface with zero existing consumers, so it speaks the uniform toolkit convention ({"action": ..., "params": ...}+ in-banddescribe) from birth, matchinghey mcpandfizzy mcpregardless of how the hosted decision lands.Curation follows the tags where they diverge from the server. The toolkit catalog claims whole tags, so the server's
checkinsdomain — whose questionnaire operations live inside the SDK's grab-bagAutomationtag alongside templates, webhooks, lineup, dock tools, and search — is served asbasecamp_automation, and the server'sadmingrab-bag lands acrossbasecamp_reports,basecamp_everything, andbasecamp_automation. Every tag is claimed:Catalog.Unmappedis pinned empty, so an SDK tag nobody has decided about fails the build. The server'sdigestsdomain (MCP App previews) has no SDK surface and is deliberately absent.Model snapshot with a bounded patch.
internal/mcpserver/model/vendors basecamp-sdk's exports atgo/v0.14.0— the version go.mod pins, enforced by test — synced byscripts/sync-mcp-model.shwith provenance recorded. The sync applies two patch tables, both pinned by tests and refused by the script once upstream catches up: tags assigned to the 16 operations the export leaves untagged (questionnaire ops →Automation, report ops → aReportstag,RepositionTodo→Todos, card-column subscriptions →Card Tables), and 3 raw-binary upload operations dropped (CreateAttachment,CreateCampfireUpload,UpdateAccountLogo— multipart/octet-stream can't ride the JSON tool-call convention; uploads stay a CLI affair viabasecamp attach/upload).Account scoping through the CLI's own plumbing. Every SDK operation is account-scoped (
/{accountId}/...); after load, the catalog is rescoped — theaccountIdparameter stripped from paths and schemas, pinned by test — and dispatch goes throughapp.Account()(*basecamp.AccountClient), which supplies the account the way every other command does. Auth, token refresh, retry, resilience gating, and observability hooks all ride along.basecamp mcprequires a configured account up front (stdio belongs to the MCP wire, so no interactive resolution) and never touches the login flow.Full surface by default, matching the server's posture. basecamp-mcp-server serves read-write by default with
BASECAMP_MCP_READ_ONLYopt-in, so the subcommand mirrors it:--read-onlynarrows to read-only actions (derived from the behavior model's per-operationreadonlytrait, not name heuristics).--domainsnarrows the served tools and fails closed on unknown keys at startup. (fizzy-cli defaulted the other way — also mirroring its server.)Dispatch conventions. Path params substituted and escaped, query params encoded (Rails-style
assignee_ids[]=1&assignee_ids[]=2arrays supported), remaining params gathered into the body with property names checked against the body schema (a typo guard — types and required properties stay the API's to enforce, with its errors surfaced in-band). Paginated listings surface the Linkrel="next"page as{"next_page": N, "results": ...}to pass back aspage; bodiless 204s answer{"status": 204}(+locationwhen present); API errors come back in-band with the SDK's rendering.Tests
internal/mcpserver/catalog_test.go): 15 domains in order, every tag claimed (Unmapped pinned empty), 247 operations, binary-upload exclusions, account rescope (noaccountIdanywhere), provenance ref == go.mod's basecamp-sdk version, and a full rendered-surface snapshot (testdata/catalog_snapshot.txt,-updateto regenerate).dispatch_test.go): path substitution/escaping, query encoding incl. Rails-style arrays, body gathering and schema-checked strays, missing/malformed params, Link-headernext_pageparsing.server_test.go): a real MCP client over the toolkit'smcptestharness, dispatching through a real basecamp-sdkAccountClientonto a fake Basecamp (httptest) that asserts bearer auth, account prefix, query encoding, and body shape, and plays back 204s, 201 creates, Link pagination, and API errors; read-only filtering (tools/list and dispatch refusal), domain narrowing, unknown-domain fail-closed.internal/commands/mcp_test.go):basecamp mcpthrough the cobra command with an in-memory transport seam — MCP initialize + tools/list + a tool call asserting the CLI's own bearer token on the upstream request; auth required, account required,--read-only/--domainspassthrough proven over the wire, unknown domains failing closed at startup.bin/ci(make check): fmt, vet, golangci-lint v2.11.1, unit tests, BATS e2e, naming, surface snapshot, skill drift, bare groups, lint lockstep, smoke coverage (mcp marked OOS — long-running stdio server, covered by the Go wire tests), SDK provenance, tidy — green, except nine pre-existing TTY-environment test failures (TestIsInteractive*, wizard tests) that fail identically on origin/main in this sandbox and pass in CI.go test -raceclean on the new packages.The nix
vendorHashmay need the CI round-trip (make update-nix-hashvalue from the nix-build job) since go.mod grewgithub.com/basecamp/mcpandgithub.com/modelcontextprotocol/go-sdk.Summary by cubic
Adds
basecamp mcp, a new subcommand that runs an MCP server on stdio serving Basecamp as 15 domain gateway tools (247 operations) backed by the CLI's signed-in account. The catalog derives from basecamp-sdk's model exports via thegithub.com/basecamp/mcptoolkit, and dispatch goes through the CLI's account-scoped SDK client so existing auth, retry, and observability plumbing ride along.New Features
--read-onlyand--domainsnarrow the surface, failing closed on unknown domains.internal/mcpserver/model/, synced byscripts/sync-mcp-model.shand pinned to the go.mod basecamp-sdk version; the sync patches tags on 16 untagged operations and drops three raw-binary uploads that can't ride the JSON tool-call convention.{"next_page": N, "results": ...}; bodiless 204 responses answer{"status": 204}.Migration
vendorHashis refreshed for the newgithub.com/basecamp/mcpandgithub.com/modelcontextprotocol/go-sdkdependencies.Written for commit 670f186. Summary will update on new commits.