Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
charm.land/bubbles/v2 v2.2.1
charm.land/bubbletea/v2 v2.0.9
charm.land/lipgloss/v2 v2.0.6
github.com/basecamp/basecamp-sdk/go v0.18.1-0.20260916130353-a8046263835c
github.com/basecamp/basecamp-sdk/go v0.18.1-0.20260916210227-4523eac74cfa
github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d
github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d
github.com/basecamp/surfguard/go v0.1.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/basecamp/basecamp-sdk/go v0.18.1-0.20260916130353-a8046263835c h1:FJQx85IjtQkA2rLAHrO77op3VRA9b5U4S6qtdjSNdIU=
github.com/basecamp/basecamp-sdk/go v0.18.1-0.20260916130353-a8046263835c/go.mod h1:kIBDYwPMMD59PadNGxpH0YTQuI+blFPZ8MelGI0RK5Q=
github.com/basecamp/basecamp-sdk/go v0.18.1-0.20260916210227-4523eac74cfa h1:efZJJSiwKn5lBih05oVfLM6S+ZYN6kB0vcH1DQmd8a8=
github.com/basecamp/basecamp-sdk/go v0.18.1-0.20260916210227-4523eac74cfa/go.mod h1:kIBDYwPMMD59PadNGxpH0YTQuI+blFPZ8MelGI0RK5Q=
github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d h1:jAzDrCCzDpIwhbFT1xVVs0z2xpXoDEkomHfKB2bUUp8=
github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d/go.mod h1:iTBTaWvsPEFIcZfkxQHEfISyJ6sZ7036K6bNx0RY3EE=
github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4=
Expand Down
75 changes: 71 additions & 4 deletions internal/mcpserver/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ package mcpserver

import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"strings"
Expand Down Expand Up @@ -42,7 +43,13 @@ func loadCatalog() (*catalog.Catalog, error) {
if err := rescopeToAccount(cat); err != nil {
return nil, err
}
synthesizePageParams(cat)
styles, err := paginationStyles(model)
if err != nil {
return nil, err
}
if err := synthesizePageParams(cat, styles); err != nil {
return nil, err
}
if err := installComposites(cat); err != nil {
return nil, err
}
Expand Down Expand Up @@ -82,18 +89,77 @@ func rescopeToAccount(cat *catalog.Catalog) error {
return nil
}

// synthesizePageParams gives every paginated operation a page query
// Pagination styles the behavior model declares, as basecamp-sdk spells them.
const (
// paginationLink pages by number, through a Link rel="next" header the
// dispatcher reads back into next_page.
paginationLink = "link"
// paginationCursor pages by an opaque position the response body
// carries (basecamp-sdk#914): the event feed's poll lanes.
paginationCursor = "cursor"
)

// paginationStyles reads each paginated operation's declared style from the
// behavior model. The toolkit's catalog keeps only whether an operation is
// paginated, not how, and the page parameter depends on how.
func paginationStyles(model fs.FS) (map[string]string, error) {
raw, err := fs.ReadFile(model, "behavior-model.json")
if err != nil {
return nil, fmt.Errorf("embedded behavior model: %w", err)
}
var doc struct {
Operations map[string]struct {
Pagination *struct {
Style string `json:"style"`
} `json:"pagination"`
} `json:"operations"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("embedded behavior model: %w", err)
}
styles := make(map[string]string, len(doc.Operations))
for id, op := range doc.Operations {
if op.Pagination != nil {
styles[id] = op.Pagination.Style
}
}
return styles, nil
}

// synthesizePageParams gives every Link-style paginated operation a page query
// parameter. The SDK export marks a handful of operations paginated without
// declaring one (ListWebhooks, ListChatbots, ...); left alone, that makes
// every page after the first unreachable over MCP — the dispatcher rejects
// parameters an operation does not declare, so the next_page value a listing
// returns could never be passed back. Synthesizing from the paginated trait
// covers whatever the model marks, and no-ops once the export declares the
// parameter itself.
func synthesizePageParams(cat *catalog.Catalog) {
//
// Only Link style, keyed off the style the model declares rather than any
// operation's name. A cursor-style operation pages by a position its own
// response carries, and a page number means nothing to it: BC3 ignores the
// parameter, so a caller passing page=2 would be served the first page again
// with nothing in the answer to say so. Those operations keep the entry-point
// parameters the model gives them (since, position) and nothing else.
//
// A paginated operation with a style this function does not recognize stops
// the load. Defaulting it either way is a silent guess — a page parameter that
// does nothing, or a listing whose later pages cannot be reached — so a new
// style has to be decided about here before the server will start.
func synthesizePageParams(cat *catalog.Catalog, styles map[string]string) error {
for _, d := range cat.Domains {
for _, op := range d.Operations {
if !op.Paginated || declaresPage(op) {
if !op.Paginated {
continue
}
switch style := styles[op.ID]; style {
case paginationCursor:
continue
case paginationLink:
default:
return fmt.Errorf("operation %q declares pagination style %q, which the page parameter has not been decided for", op.ID, style)
}
if declaresPage(op) {
continue
}
op.Params = append(op.Params, catalog.Param{
Expand All @@ -104,6 +170,7 @@ func synthesizePageParams(cat *catalog.Catalog) {
})
}
}
return nil
}

func declaresPage(op *catalog.Operation) bool {
Expand Down
97 changes: 89 additions & 8 deletions internal/mcpserver/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,20 +155,51 @@ func TestCatalogIsAccountScoped(t *testing.T) {
}
}

// modelPaginationStylesForTest reads each operation's declared pagination style
// straight from the vendored behavior model, independently of the loader, so
// the page-parameter tests check the catalog against the model rather than
// against the code under test.
func modelPaginationStylesForTest(t *testing.T) map[string]string {
t.Helper()
raw, err := modelFS.ReadFile("model/behavior-model.json")
require.NoError(t, err)
var model struct {
Operations map[string]struct {
Pagination *struct {
Style string `json:"style"`
} `json:"pagination"`
} `json:"operations"`
}
require.NoError(t, json.Unmarshal(raw, &model))
styles := map[string]string{}
for id, op := range model.Operations {
if op.Pagination != nil {
styles[id] = op.Pagination.Style
}
}
return styles
}

// TestCatalogPaginatedActionsTakePage pins the synthesized page parameter:
// every operation the behavior model marks paginated must declare a page
// query parameter, whether the OpenAPI export supplies it or loadCatalog
// synthesizes it. Otherwise the next_page value a listing returns could
// never be passed back — the dispatcher rejects undeclared parameters.
// every Link-style paginated operation must declare exactly one page query
// parameter, whether the OpenAPI export supplies it or loadCatalog
// synthesizes it. Otherwise the next_page value a listing returns could never
// be passed back — the dispatcher rejects undeclared parameters.
//
// 61 is the Link-style count. The model marks 63 operations paginated; the
// other two are the event feed's poll lanes (PollEvents, PollInbox), which
// basecamp-sdk#914 declares cursor-style. They page by position, not by page
// number, and are pinned separately below.
func TestCatalogPaginatedActionsTakePage(t *testing.T) {
cat := loadForTest(t)
paginated := 0
styles := modelPaginationStylesForTest(t)
link := 0
for _, d := range cat.Domains {
for _, op := range d.Operations {
if !op.Paginated {
if !op.Paginated || styles[op.ID] != "link" {
continue
}
paginated++
link++
pages := 0
for _, p := range op.Params {
if p.In != "query" || p.Name != "page" {
Expand All @@ -181,7 +212,39 @@ func TestCatalogPaginatedActionsTakePage(t *testing.T) {
assert.Equal(t, 1, pages, "operation %q must declare exactly one page query parameter", op.ID)
}
}
assert.Equal(t, 61, paginated, "paginated operation count")
assert.Equal(t, 61, link, "Link-style paginated operation count")
}

// TestCatalogCursorPaginatedActionsTakeNoPage pins the other half. A
// cursor-style operation pages by an opaque position its own response
// carries, and a Link-style page number means nothing to it: BC3 ignores it,
// so a caller who passes page=2 is served page one again and told nothing.
// The page parameter is keyed off the style the model declares, so this also
// checks that every operation the model calls paginated has a style the
// loader recognizes — a new style must be decided about, not defaulted.
func TestCatalogCursorPaginatedActionsTakeNoPage(t *testing.T) {
cat := loadForTest(t)
styles := modelPaginationStylesForTest(t)
cursor := 0
for _, d := range cat.Domains {
for _, op := range d.Operations {
if !op.Paginated {
continue
}
style := styles[op.ID]
assert.Contains(t, []string{"link", "cursor"}, style,
"operation %q declares pagination style %q, which the loader has not decided about", op.ID, style)
if style != "cursor" {
continue
}
cursor++
for _, p := range op.Params {
assert.False(t, p.In == "query" && p.Name == "page",
"cursor-style operation %q must not take a Link-style page parameter", op.ID)
}
}
}
assert.Equal(t, 2, cursor, "cursor-style operation count (the event feed's two poll lanes)")
}

// TestCatalogSnapshot renders the full served surface — every tool
Expand Down Expand Up @@ -256,3 +319,21 @@ func pseudoVersionCommit(version string) (string, bool) {
}
return match[2], true
}

// A paginated operation whose declared style the loader has not decided about
// stops the load rather than being defaulted. Either default is a silent
// guess: a page parameter the server ignores, or a listing whose later pages
// cannot be reached. The load failing is how a model sync that brings a new
// style gets a decision instead of a guess.
func TestCatalogRefusesAnUndecidedPaginationStyle(t *testing.T) {
cat := &catalog.Catalog{Domains: []*catalog.Domain{{
Key: "probe",
Operations: []*catalog.Operation{{ID: "ListThings", Paginated: true}},
}}}

err := synthesizePageParams(cat, map[string]string{"ListThings": "offset"})

require.Error(t, err, "an undecided style must stop the load")
assert.Contains(t, err.Error(), `"offset"`)
assert.Empty(t, cat.Domains[0].Operations[0].Params, "nothing may be synthesized for an undecided style")
}
4 changes: 2 additions & 2 deletions internal/mcpserver/model/PROVENANCE.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"source": "github.com/basecamp/basecamp-sdk",
"commit": "a8046263835c559be89e3b081a0428c131a4c663",
"ref": "go/v0.18.0-51-ga8046263",
"commit": "4523eac74cfa6452ec85ea5f8f633baa4dc487a5",
"ref": "go/v0.18.0-63-g4523eac7",
"files": ["behavior-model.json", "openapi.json"],
"synced_by": "scripts/sync-mcp-model.sh",
"patches": "binary-upload operations dropped because the toolkit refuses their non-JSON bodies (EXCLUDED_OPERATIONS) and the stream-ticket mint dropped by policy (POLICY_EXCLUDED_OPERATIONS); no tag patches applied (PATCHED_TAGS is empty — the export tags every operation) — see the sync script"
Expand Down
8 changes: 8 additions & 0 deletions internal/mcpserver/model/behavior-model.json
Original file line number Diff line number Diff line change
Expand Up @@ -2534,6 +2534,10 @@
},
"PollEvents": {
"readonly": true,
"pagination": {
"style": "cursor",
"maxPageSize": 100
},
"retry": {
"max": 3,
"base_delay_ms": 1000,
Expand All @@ -2546,6 +2550,10 @@
},
"PollInbox": {
"readonly": true,
"pagination": {
"style": "cursor",
"maxPageSize": 100
},
"retry": {
"max": 3,
"base_delay_ms": 1000,
Expand Down
14 changes: 12 additions & 2 deletions internal/mcpserver/model/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8377,7 +8377,7 @@
},
"/{accountId}/events.json": {
"get": {
"description": "Poll the account event feed for events after a position (oldest first, strict event-id order, up to 100 per page).\n\n**Entry.** With neither `since` nor `position` the feed begins at the present\n(equivalent to `since=now`). `since=<event id>` starts after that id and\n`since=0` replays all served history back to the feed's epoch; `since` is a\nsigned 64-bit integer written in decimal, or the literal `now`. `position`\nresumes from a token a previous page issued \u2014 signed, opaque, bound to the\naccount and the filter set.\n\n**Pagination**: the body envelope, not the Link header. `position` is the\ndurable cursor (persist it only after processing the page's events); `next`\nis an absolute continuation URL present only while this walk has more to\nserve. Not wired into the generic Link paginator \u2014 see the section note.\n\n**Errors.** 400 (FeedRequestError) for a malformed position (resume with\n`since=`) or a malformed filter (fix the filters; a position reset will not\nhelp), told apart by its optional `reason` and undifferentiated when `reason`\nis absent. 409 (FeedFilterMismatchError) when the position was minted for a\ndifferent filter set. 410 (FeedPositionGoneError) when the position predates\nthe feed's epoch; its `resume` re-enters at the epoch.",
"description": "Poll the account event feed for events after a position (oldest first, strict event-id order, up to 100 per page).\n\n**Entry.** With neither `since` nor `position` the feed begins at the present\n(equivalent to `since=now`). `since=<event id>` starts after that id and\n`since=0` replays all served history back to the feed's epoch; `since` is a\nsigned 64-bit integer written in decimal, or the literal `now`. `position`\nresumes from a token a previous page issued \u2014 signed, opaque, bound to the\naccount and the filter set.\n\n**Pagination**: cursor style \u2014 the body envelope, not the Link header.\n`position` is the durable cursor (persist it only after processing the\npage's events); `next` is an absolute continuation URL present only while\nthis walk has more to serve. One call answers one page; no generator emits\na walk for the cursor style \u2014 see the section note.\n\n**Errors.** 400 (FeedRequestError) for a malformed position (resume with\n`since=`) or a malformed filter (fix the filters; a position reset will not\nhelp), told apart by its optional `reason` and undifferentiated when `reason`\nis absent. 409 (FeedFilterMismatchError) when the position was minted for a\ndifferent filter set. 410 (FeedPositionGoneError) when the position predates\nthe feed's epoch; its `resume` re-enters at the epoch.",
"operationId": "PollEvents",
"parameters": [
{
Expand Down Expand Up @@ -8549,6 +8549,11 @@
"tags": [
"EventFeed"
],
"x-basecamp-pagination": {
"style": "cursor",
"key": "events",
"maxPageSize": 100
},
"x-basecamp-retry": {
"maxAttempts": 3,
"baseDelayMs": 1000,
Expand Down Expand Up @@ -9302,7 +9307,7 @@
},
"/{accountId}/inbox.json": {
"get": {
"description": "Poll the authenticated agent's inbox for the items that addressed it (oldest first, strict item-id order); people receive 403.\n\nThe inbox is the low-noise \"someone addressed you\" lane as its own resource\nrather than a filter over the account feed. **Agents only for now**: any\nother principal receives 403.\n\nAn item is a first-class delivery with its own identity: one event can\naddress the same principal for several reasons, and each reason is its own\nitem. Deduplicate by `addressing_id`, never by event id. Items are never\nself-addressed, are kept for 30 days, and are dropped at read time when the\nevent is no longer readable.\n\n**Entry**: `since=0` replays the earliest retained items, `since=now` enters\nat the present, `position` resumes. Inbox positions are bound to the\naccount, the principal, and the filter set, and are never interchangeable\nwith feed positions.\n\n**Pagination**: the body envelope (`items`, `position`, `next`), exactly as\nPollEvents \u2014 not the Link header, and not the generic paginator.\n\n**Errors** follow PollEvents (FeedRequestError 400, FeedFilterMismatchError\n409), except that 403 carries no body \u2014 the agent guard's bare\n`head :forbidden` (BareForbiddenError) \u2014 and that the 410 is the inbox's own\nInboxPositionGoneError: the position fell behind the retention window, there\nis no epoch, and `resume` re-enters at `since=0`, the earliest retained item\n\u2014 not the feed's recovery, and not interchangeable with it.",
"description": "Poll the authenticated agent's inbox for the items that addressed it (oldest first, strict item-id order); people receive 403.\n\nThe inbox is the low-noise \"someone addressed you\" lane as its own resource\nrather than a filter over the account feed. **Agents only for now**: any\nother principal receives 403.\n\nAn item is a first-class delivery with its own identity: one event can\naddress the same principal for several reasons, and each reason is its own\nitem. Deduplicate by `addressing_id`, never by event id. Items are never\nself-addressed, are kept for 30 days, and are dropped at read time when the\nevent is no longer readable.\n\n**Entry**: `since=0` replays the earliest retained items, `since=now` enters\nat the present, `position` resumes. Inbox positions are bound to the\naccount, the principal, and the filter set, and are never interchangeable\nwith feed positions.\n\n**Pagination**: cursor style, exactly as PollEvents \u2014 the body envelope\n(`items`, `position`, `next`), not the Link header and not the generic\npaginator. Up to 100 items per page.\n\n**Errors** follow PollEvents (FeedRequestError 400, FeedFilterMismatchError\n409), except that 403 carries no body \u2014 the agent guard's bare\n`head :forbidden` (BareForbiddenError) \u2014 and that the 410 is the inbox's own\nInboxPositionGoneError: the position fell behind the retention window, there\nis no epoch, and `resume` re-enters at `since=0`, the earliest retained item\n\u2014 not the feed's recovery, and not interchangeable with it.",
"operationId": "PollInbox",
"parameters": [
{
Expand Down Expand Up @@ -9440,6 +9445,11 @@
"tags": [
"EventFeed"
],
"x-basecamp-pagination": {
"style": "cursor",
"key": "items",
"maxPageSize": 100
},
"x-basecamp-retry": {
"maxAttempts": 3,
"baseDelayMs": 1000,
Expand Down
4 changes: 2 additions & 2 deletions internal/mcpserver/testdata/catalog_snapshot.txt
Original file line number Diff line number Diff line change
Expand Up @@ -320,8 +320,8 @@ Gateway tool: call with {"action": "...", "params": {...}}.
Call {"action": "describe", "params": {"action": "NAME"}} for an action's full parameter schema.

ACTIONS (RO = read-only):
- poll_events (RO): Poll the account event feed for events after a position (oldest first, strict event-id order, up to 100 per page)
- poll_inbox (RO): Poll the authenticated agent's inbox for the items that addressed it (oldest first, strict item-id order); people receive 403
- poll_events (RO, paginated): Poll the account event feed for events after a position (oldest first, strict event-id order, up to 100 per page)
- poll_inbox (RO, paginated): Poll the authenticated agent's inbox for the items that addressed it (oldest first, strict item-id order); people receive 403

== basecamp_clientside
The Clientside: client approvals, correspondences, replies, and client visibility of recordings.
Expand Down
6 changes: 3 additions & 3 deletions internal/version/sdk-provenance.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"sdk": {
"module": "github.com/basecamp/basecamp-sdk/go",
"version": "v0.18.1-0.20260916130353-a8046263835c",
"revision": "a8046263835c",
"updated_at": "2026-09-16T13:03:53Z"
"version": "v0.18.1-0.20260916210227-4523eac74cfa",
"revision": "4523eac74cfa",
"updated_at": "2026-09-16T21:02:27Z"
},
"api": {
"repo": "basecamp/bc3",
Expand Down
2 changes: 1 addition & 1 deletion nix/package.nix
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ buildGoModule.override { go = go_1_26; } (finalAttrs: {
src = lib.cleanSource ./..;

# To update: set to lib.fakeHash, run `nix build`, use the hash from the error.
vendorHash = "sha256-eTROZTeU4FbooBGNSKiuqH8ohtXhvnDrHTB69pACHL4=";
vendorHash = "sha256-uBf5VeKAMnOAPHLIwvaKd/4arzVPhmsa4kCWizKXEn0=";

subPackages = [ "cmd/basecamp" ];

Expand Down