Uh oh!
There was an error while loading. Please reload this page.
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.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
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".
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
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.