Skip to content

Serve Basecamp over MCP with basecamp mcp - #662

Merged
jeremy merged 2 commits into
mainfrom
mcp-subcommand
Aug 28, 2026
Merged

Serve Basecamp over MCP with basecamp mcp#662
jeremy merged 2 commits into
mainfrom
mcp-subcommand

Conversation

@jeremy

@jeremy jeremy commented Aug 28, 2026

Copy link
Copy Markdown
Member

What

basecamp mcp runs 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:

claude mcp add basecamp -- basecamp mcp
basecamp mcp --read-only                 # serve only read-only actions
basecamp mcp --domains projects,todos    # narrow the served surface (fails closed on unknown keys)

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-server is 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 — the basecamp/mcp toolkit (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-band describe) from birth, matching hey mcp and fizzy mcp regardless 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 checkins domain — whose questionnaire operations live inside the SDK's grab-bag Automation tag alongside templates, webhooks, lineup, dock tools, and search — is served as basecamp_automation, and the server's admin grab-bag lands across basecamp_reports, basecamp_everything, and basecamp_automation. Every tag is claimed: Catalog.Unmapped is pinned empty, so an SDK tag nobody has decided about fails the build. The server's digests domain (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 at go/v0.14.0 — the version go.mod pins, enforced by test — synced by scripts/sync-mcp-model.sh with 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 → a Reports tag, RepositionTodoTodos, 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 via basecamp attach/upload).

Account scoping through the CLI's own plumbing. Every SDK operation is account-scoped (/{accountId}/...); after load, the catalog is rescoped — the accountId parameter stripped from paths and schemas, pinned by test — and dispatch goes through app.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 mcp requires 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_ONLY opt-in, so the subcommand mirrors it: --read-only narrows to read-only actions (derived from the behavior model's per-operation readonly trait, not name heuristics). --domains narrows 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[]=2 arrays 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 Link rel="next" page as {"next_page": N, "results": ...} to pass back as page; bodiless 204s answer {"status": 204} (+location when present); API errors come back in-band with the SDK's rendering.

Tests

  • Catalog pins (internal/mcpserver/catalog_test.go): 15 domains in order, every tag claimed (Unmapped pinned empty), 247 operations, binary-upload exclusions, account rescope (no accountId anywhere), provenance ref == go.mod's basecamp-sdk version, and a full rendered-surface snapshot (testdata/catalog_snapshot.txt, -update to regenerate).
  • Request-builder tables (dispatch_test.go): path substitution/escaping, query encoding incl. Rails-style arrays, body gathering and schema-checked strays, missing/malformed params, Link-header next_page parsing.
  • Wire tests (server_test.go): a real MCP client over the toolkit's mcptest harness, dispatching through a real basecamp-sdk AccountClient onto 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.
  • Command-level end-to-end (internal/commands/mcp_test.go): basecamp mcp through 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/--domains passthrough 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 -race clean on the new packages.

The nix vendorHash may need the CI round-trip (make update-nix-hash value from the nix-build job) since go.mod grew github.com/basecamp/mcp and github.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 the github.com/basecamp/mcp toolkit, and dispatch goes through the CLI's account-scoped SDK client so existing auth, retry, and observability plumbing ride along.

New Features

  • Serves read-write by default; --read-only and --domains narrow the surface, failing closed on unknown domains.
  • Requires sign-in and a configured account up front because stdio belongs to the MCP wire and can't resolve an account interactively.
  • Vendors the SDK model snapshot in internal/mcpserver/model/, synced by scripts/sync-mcp-model.sh and 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.
  • Paginated listings wrap the next page as {"next_page": N, "results": ...}; bodiless 204 responses answer {"status": 204}.

Migration

  • nix vendorHash is refreshed for the new github.com/basecamp/mcp and github.com/modelcontextprotocol/go-sdk dependencies.

Written for commit 670f186. Summary will update on new commits.

Review in cubic

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.
Copilot AI balanced review requested due to automatic review settings August 28, 2026 16:59
@github-actions github-actions Bot added commands CLI command implementations tests Tests (unit and e2e) docs deps labels Aug 28, 2026
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/mcpserver/dispatch.go
Comment thread go.mod
Copilot AI review requested due to automatic review settings August 28, 2026 17:04
@jeremy
jeremy merged commit db1ff0c into main Aug 28, 2026
35 checks passed
@jeremy
jeremy deleted the mcp-subcommand branch August 28, 2026 17:05

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread go.mod
Comment thread internal/mcpserver/dispatch.go
Comment thread internal/commands/mcp.go

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • nextPage returns the query value as a string, so this emits "next_page":"2", while every advertised page parameter is an integer and the documented wrapper uses a numeric N. 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 AccountClient request methods bypass the SDK's semantic operation path. As a result, the GatingHooks installed by appctx never 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)

jeremy added a commit that referenced this pull request Aug 28, 2026
* 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

commands CLI command implementations deps docs tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants