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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@ Sixteen domain tools (`basecamp_projects`, `basecamp_todos`, `basecamp_cards`,
account. Each tool takes `{"action": "...", "params": {...}}` and serves
per-action schemas through its `describe` action.

Two composite helpers ride on that surface, shared with basecamp-mcp-server
Composite helpers ride on that surface, shared with basecamp-mcp-server
through basecamp-sdk so both servers answer the same way.
`basecamp_recordings summarize` resolves the pointer an account event feed
row carries — bucket id, recording id, and the event or recording type —
Expand All @@ -471,7 +471,10 @@ it carries no method and no path, where every model operation carries both,
and its summary opens with "Synthetic:". `basecamp_messages create_comment`
is an ordinary endpoint with a synthetic parameter: `mentions`, a list of
person ids expanded client-side into the attachment markup Basecamp reads as
a mention.
a mention. `basecamp_recordings move` and `copy` file a recording into
another container, in its project or another one, as the web's Move… and
Copy… dialogs do: they wait for Basecamp to finish and return the new
recording, and a failure says whether trying again is safe.

```bash
basecamp mcp --read-only # serve only read-only actions
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ require (
charm.land/bubbles/v2 v2.2.1
charm.land/bubbletea/v2 v2.1.0
charm.land/lipgloss/v2 v2.0.6
github.com/basecamp/basecamp-sdk/go v0.31.0
github.com/basecamp/basecamp-sdk/go v0.32.0
github.com/basecamp/cli v0.2.2-0.20261023171439-c9bce8206df9
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 @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/basecamp/actioncable-go v1.2.0 h1:t0MxlfMTcmaGXZ5ztgKRs7ZiY1MwXtBRwSSSAh1ofVg=
github.com/basecamp/actioncable-go v1.2.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs=
github.com/basecamp/basecamp-sdk/go v0.31.0 h1:+gAcD0hMiEt4VWP4VOSOr/U3crjVa6dfT/NjmPN2JDo=
github.com/basecamp/basecamp-sdk/go v0.31.0/go.mod h1:mUm/MQGMyg1E1cddyxdG7tQHiq0NsYee2zUeAUT5VjU=
github.com/basecamp/basecamp-sdk/go v0.32.0 h1:HWRxJGhlaxzh7f/2+cxax664dN5BgjfM+Our3rEWuZU=
github.com/basecamp/basecamp-sdk/go v0.32.0/go.mod h1:mUm/MQGMyg1E1cddyxdG7tQHiq0NsYee2zUeAUT5VjU=
github.com/basecamp/cli v0.2.2-0.20261023171439-c9bce8206df9 h1:3NfGs+zx23Tp6cf+97+UewrJHlzZMLzmvEXOmOJyQAk=
github.com/basecamp/cli v0.2.2-0.20261023171439-c9bce8206df9/go.mod h1:iTBTaWvsPEFIcZfkxQHEfISyJ6sZ7036K6bNx0RY3EE=
github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4=
Expand Down
13 changes: 9 additions & 4 deletions internal/mcpserver/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ func TestCatalogClaimsEveryTag(t *testing.T) {
// upload). The stream-ticket mint could ride it and is dropped anyway: its
// result is a replayable bearer and a URL embedding it, the dispatcher
// returns a result verbatim into a model transcript with no reveal opt-in,
// and nothing on this surface could open the WebSocket it is for.
// and nothing on this surface could open the WebSocket it is for. The
// start-only move and copy are dropped at load instead, where the waiting
// composites take their place.
func TestCatalogExcludesUnservedOperations(t *testing.T) {
cat := loadForTest(t)
excluded := map[string]bool{
Expand All @@ -61,6 +63,9 @@ func TestCatalogExcludesUnservedOperations(t *testing.T) {
"UpdateProjectWallpaper": true,
"UpdateProjectLogo": true,
"CreateStreamTicket": true,
// Replaced by the waiting recordings.move and recordings.copy.
"MoveRecording": true,
"CopyRecording": true,
}
model, composite := 0, 0
for _, d := range cat.Domains {
Expand All @@ -73,8 +78,8 @@ func TestCatalogExcludesUnservedOperations(t *testing.T) {
assert.False(t, excluded[op.ID], "operation %q should be excluded from the vendored model", op.ID)
}
}
assert.Equal(t, 286, model, "served model operation count")
assert.Equal(t, 1, composite, "served composite operation count")
assert.Equal(t, 284, model, "served model operation count")
assert.Equal(t, 3, composite, "served composite operation count")
}

// isComposite reports the SDK compositions this package serves itself: no
Expand Down Expand Up @@ -105,7 +110,7 @@ func TestCatalogMarksCompositesSynthetic(t *testing.T) {
assert.NotEmpty(t, op.Doc, "composite %q must document what it composes", op.ID)
}
}
assert.Equal(t, map[string]bool{"recordings.summarize": true}, seen)
assert.Equal(t, map[string]bool{"recordings.summarize": true, "recordings.move": true, "recordings.copy": true}, seen)
}

// TestCatalogDeclaresMentionsOnCreateComment pins the other half of the
Expand Down
43 changes: 30 additions & 13 deletions internal/mcpserver/composite.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const (
mentionsParam = "mentions"
syntheticSummaryTag = "Synthetic: "
recordingsDomainTag = "Recordings"
recordingsDomainDesc = "Recordings: the compact projection of one recording, resolved from the pointer an account event feed row carries."
recordingsDomainDesc = "Recordings: the compact projection of one recording, resolved from the pointer an account event feed row carries; and moving or copying a recording into another container or project, waiting for Basecamp to finish and returning the new recording."
)

// maxExactJSONInteger is the largest integer a JSON number carries without
Expand Down Expand Up @@ -84,6 +84,8 @@ type compositeHandler func(ctx context.Context, api API, op *catalog.Operation,
// dispatcher holds when a call arrives.
var compositeHandlers = map[string]compositeHandler{
recordingsDomainKey + "." + summarizeAction: handleSummarize,
recordingsDomainKey + "." + moveAction: handleFiling(basecamp.FilingMove),
recordingsDomainKey + "." + copyAction: handleFiling(basecamp.FilingCopy),
}

// recordingsDomain is the hand-built domain carrying the composite reads.
Expand All @@ -96,6 +98,8 @@ func recordingsDomain() *catalog.Domain {
Tool: recordingsToolName,
Blurb: recordingsDomainDesc,
Operations: []*catalog.Operation{
filingOperation(basecamp.FilingCopy),
filingOperation(basecamp.FilingMove),
{
ID: "SummarizeRecording",
Action: summarizeAction,
Expand Down Expand Up @@ -131,7 +135,8 @@ func recordingsDomain() *catalog.Domain {
}

// installComposites adds the composite surface to a loaded catalog: the
// recordings domain, and the mentions parameter on messages.create_comment.
// recordings domain, which takes over the model's move, copy and filing
// operations, and the mentions parameter on messages.create_comment.
// It runs after rescopeToAccount, which walks model paths the composite
// operations deliberately do not have.
func installComposites(cat *catalog.Catalog) error {
Expand All @@ -140,7 +145,11 @@ func installComposites(cat *catalog.Catalog) error {
return fmt.Errorf("the model already serves a %q domain; the composite domain would shadow it", recordingsDomainKey)
}
}
cat.Domains = append(cat.Domains, recordingsDomain())
recordings := recordingsDomain()
if err := installFilingOperations(cat, recordings); err != nil {
return err
}
cat.Domains = append(cat.Domains, recordings)
return installMentionsParam(cat)
}

Expand Down Expand Up @@ -208,16 +217,8 @@ func handleSummarize(ctx context.Context, api API, op *catalog.Operation, params

func summarizeRef(op *catalog.Operation, params map[string]any) (basecamp.RecordingRef, error) {
var ref basecamp.RecordingRef
// What is accepted is what is advertised: the same body schema describe
// serves, read here rather than restated, so the two cannot drift.
known, _ := op.Body["properties"].(map[string]any)
if len(known) == 0 {
return ref, fmt.Errorf("internal error: action %q declares no parameters", op.Action)
}
for name := range params {
if _, ok := known[name]; !ok {
return ref, fmt.Errorf("unknown parameter %q for action %q (describe the action for its schema)", name, op.Action)
}
if err := refuseUnknownParams(op, params); err != nil {
return ref, err
}

var err error
Expand All @@ -239,6 +240,22 @@ func summarizeRef(op *catalog.Operation, params map[string]any) (basecamp.Record
return ref, nil
}

// refuseUnknownParams holds a composite's parameters to what it advertises:
// the same body schema describe serves, read here rather than restated, so
// the two cannot drift.
func refuseUnknownParams(op *catalog.Operation, params map[string]any) error {
known, _ := op.Body["properties"].(map[string]any)
if len(known) == 0 {
return fmt.Errorf("internal error: action %q declares no parameters", op.Action)
}
for name := range params {
if _, ok := known[name]; !ok {
return fmt.Errorf("unknown parameter %q for action %q (describe the action for its schema)", name, op.Action)
}
}
return nil
}

// errNoTypeGiven is a pointer with neither type. The SDK reaches the same
// verdict for a pointer it cannot route, so this wraps that sentinel and is
// reported under the same identity: a caller branching on the error's type
Expand Down
21 changes: 19 additions & 2 deletions internal/mcpserver/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ import (
//
// The four verbs serve every model operation but the feed's two poll lanes.
// The recordings and comments services serve the composite actions (see
// composite.go), which are SDK compositions rather than single requests and so
// cannot be assembled from a method and a path. The event feed service serves
// composite.go and filing.go), which are SDK compositions rather than single
// requests and so cannot be assembled from a method and a path. The event feed service serves
// poll_events and poll_inbox, whose refusals carry the data a consumer resumes
// from and whose bodies the verbs above throw away (see feed.go).
//
Expand All @@ -44,6 +44,23 @@ type API interface {
Recordings() *basecamp.RecordingsService
Comments() *basecamp.CommentsService
EventFeed() *basecamp.EventFeedService

// The typed reads and the project dock that move and copy resolve a
// recording and its destination through (see filing.go).
Projects() *basecamp.ProjectsService
Cards() *basecamp.CardsService
CardColumns() *basecamp.CardColumnsService
Messages() *basecamp.MessagesService
Documents() *basecamp.DocumentsService
Uploads() *basecamp.UploadsService
CloudFiles() *basecamp.CloudFilesService
GoogleDocuments() *basecamp.GoogleDocumentsService
Vaults() *basecamp.VaultsService
Schedules() *basecamp.SchedulesService
Checkins() *basecamp.CheckinsService
Forwards() *basecamp.ForwardsService
Todolists() *basecamp.TodolistsService
Todos() *basecamp.TodosService
}

// The CLI hands its account-scoped client straight to New.
Expand Down
Loading
Loading