Skip to content

Serve move and copy that wait for Basecamp in basecamp mcp - #988

Merged
robzolkos merged 5 commits into
mainfrom
mcp-filing-wait
Oct 28, 2026
Merged

robzolkos merged 5 commits into
mainfrom
mcp-filing-wait

Conversation

@robzolkos

@robzolkos robzolkos commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Why

Agents on basecamp mcp could only start a move or copy with move_recording or copy_recording. They then had to poll get_filing and look up the new recording themselves. The CLI's own move and copy commands already wait and answer with the new recording. This change gives the MCP server the same behavior through basecamp-sdk v0.32.0's MoveAndWait and CopyAndWait.

What changed

  • basecamp_recordings move and copy take a recording id and type and a destination. The destination is project_id (its one enabled tool of the recording's kind, or its one to-do list) or container_id, with folder_id for Docs & Files and include_comments for a copy. Each call starts the filing once, waits up to a minute, and returns the new recording with source_id.
  • A failure carries error.type and error.retry_safe. A project with the tool turned off names the tool_id to enable. A project with several candidates lists them for container_id. A filing still running when the wait ends carries the filing_id to check with get_filing. An id that now redirects to a moved recording is refused as recording_moved, so a retried move never files the recording twice.
  • move_recording and copy_recording are removed. get_filing moves from basecamp_automation to basecamp_recordings, so the check is always next to the actions that need it.
  • The SDK is bumped to v0.32.0 and the vendored MCP model is synced to it.

Scope

  • Covers the 14 kinds the web's Move… and Copy… dialogs file: cards, card columns, messages, documents, uploads, cloud files, Google documents, folders, schedule entries, check-in questions, email forwards, to-dos, to-do lists and to-do list groups.
  • Leaves out whole dock tools (a project's Docs & Files or card table), as the CLI does. basecamp_cards move_card (column moves and wormholes) uses a different endpoint and is unchanged.
  • A recurring event files as its whole series, as in the CLI. The answer names the series and keeps the occurrence Basecamp read as occurrence_id.
  • One recording per call. No bulk form.

Tradeoffs

  • recording_type is required. Basecamp has no generic recording read, and the type selects the typed read that reports the parent, which tells a to-do list from a group and a folder from a whole Docs & Files.
  • No start-only twin stays beside the waiting actions. The gateway does not need one, and an agent shown both would have to pick.

Blast Radius

A client that calls move_recording, copy_recording or basecamp_automation get_filing now gets an unknown-action error and must use basecamp_recordings. basecamp_recordings now has write actions, so its tool-level read-only hint is off. --read-only drops move and copy and keeps summarize and get_filing.

Verification

  • bin/ci green on this head.
  • TestFilingServesEveryTypeTheSDKFiles asks FilingContainerFor about every recordable type (bc3's models, the SDK's constants and every type name in the vendored model, with each as a parent) and drives a move and a copy through the MCP wire for each of the 14 routes it accepts. It fails when the SDK files a type this server cannot read or a container kind it cannot resolve. It fails with Inbox::Forward removed from the read table, which was checked.
  • Live matrix: 64 of 64 rows passed through the built binary over stdio (initialize, then tools/call basecamp_recordings) against a dev bc3 with a job worker. Each kind ran move within its project, move to another project, copy in place and copy to another project. Extra rows covered folder placement in another project, include_comments, three refusals (tool turned off, two candidate message boards, a folder passed as the container), a recurring event moved and copied to another project, and a retried move refused. An independent API read proved each row: the new id is in the expected project and container, a moved id answers 302, and a copied source is unchanged. A filing_unsettled answer was not provoked live, because bc3 finishes in under 2 seconds and the wait is a minute; unit tests cover it. The driver is in the first comment below.

Syncs the vendored MCP model to the tag (no model changes) and refreshes the Nix vendorHash.
basecamp_recordings move and copy take a recording and a destination (a
project, or a container plus an optional folder), start the filing once
through the SDK's MoveAndWait or CopyAndWait, wait for Basecamp to finish,
and answer with the new recording read back at its new id. A failure says
how far it got and whether calling again is safe.

They replace the model's start-only move_recording and copy_recording,
which answered with a filing to poll. get_filing moves into
basecamp_recordings beside them, as the check for a filing that was still
running when the wait ended.
Copilot AI balanced review requested due to automatic review settings October 28, 2026 19:45
@robzolkos
robzolkos requested a review from a team as a code owner October 28, 2026 19:45
@robzolkos

robzolkos commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

Live matrix driver, for rerunning the rows against a dev bc3 with a job worker (API on 127.0.0.1:4411, as the verify-basecamp-cli skill launches it).

# env: BASECAMP_BASE_URL=http://127.0.0.1:4411 BASECAMP_ACCOUNT_ID=181900405, and BASECAMP_TOKEN read at
# runtime from bc3's `pat` fixture (test/fixtures/oauth/access_tokens.yml). `basecamp` v0.20.1 on PATH seeds
# the upload and forward the API cannot create.
go build -o /tmp/mcp-matrix/basecamp ./cmd/basecamp
python3 setup.py fixtures.json        # MCP CLI Lab A/B/C projects, second tools in A, seeds
python3 matrix.py /tmp/mcp-matrix/basecamp fixtures.json matrix   # writes matrix.md and matrix.json

The table from this head (e846a39). Rows 62 to 64 ran by hand on the same head: a recurring event moved and copied to project B with the released CLI seeding it, and a completed move retried with its old id:

# kind operation source new API checks verdict
1 card move within project 1069485280 1069485282 GET new 200; bucket 1042979315; in 1069484017; GET old 302 PASS
2 card move to other project 1069485285 1069485287 GET new 200; bucket 1042979316; in 1069483972; GET old 302 PASS
3 card copy in place 1069485290 1069485292 GET new 200; bucket 1042979315; in 1069483956; source unchanged PASS
4 card copy to other project 1069485290 1069485294 GET new 200; bucket 1042979316; in 1069483972; source unchanged PASS
5 column move within project 1069485296 1069485297 GET new 200; bucket 1042979315; in 1069484017; GET old 302 PASS
6 column move to other project 1069485298 1069485299 GET new 200; bucket 1042979316; in 1069483972; GET old 302 PASS
7 column copy in place 1069485300 1069485301 GET new 200; bucket 1042979315; in 1069483956; source unchanged PASS
8 column copy to other project 1069485300 1069485302 GET new 200; bucket 1042979316; in 1069483972; source unchanged PASS
9 message move within project 1069485303 1069485305 GET new 200; bucket 1042979315; in 1069484016; GET old 302 PASS
10 message move to other project 1069485308 1069485310 GET new 200; bucket 1042979316; in 1069483967; GET old 302 PASS
11 message copy in place 1069485313 1069485315 GET new 200; bucket 1042979315; in 1069483951; source unchanged PASS
12 message copy to other project 1069485313 1069485317 GET new 200; bucket 1042979316; in 1069483967; source unchanged PASS
13 document move within project 1069485319 1069485321 GET new 200; bucket 1042979315; in 1069484030; GET old 302 PASS
14 document move to other project 1069485324 1069485326 GET new 200; bucket 1042979316; in 1069483969; GET old 302 PASS
15 document copy in place 1069485328 1069485331 GET new 200; bucket 1042979315; in 1069483953; source unchanged PASS
16 document copy to other project 1069485328 1069485333 GET new 200; bucket 1042979316; in 1069483969; source unchanged PASS
17 upload move within project 1069485335 1069485337 GET new 200; bucket 1042979315; in 1069484030; GET old 302 PASS
18 upload move to other project 1069485339 1069485341 GET new 200; bucket 1042979316; in 1069483969; GET old 302 PASS
19 upload copy in place 1069485343 1069485345 GET new 200; bucket 1042979315; in 1069483953; source unchanged PASS
20 upload copy to other project 1069485343 1069485347 GET new 200; bucket 1042979316; in 1069483969; source unchanged PASS
21 cloud file move within project 1069485349 1069485351 GET new 200; bucket 1042979315; in 1069484030; GET old 302 PASS
22 cloud file move to other project 1069485353 1069485355 GET new 200; bucket 1042979316; in 1069483969; GET old 302 PASS
23 cloud file copy in place 1069485357 1069485359 GET new 200; bucket 1042979315; in 1069483953; source unchanged PASS
24 cloud file copy to other project 1069485357 1069485361 GET new 200; bucket 1042979316; in 1069483969; source unchanged PASS
25 google document move within project 1069485363 1069485365 GET new 200; bucket 1042979315; in 1069484030; GET old 302 PASS
26 google document move to other project 1069485368 1069485370 GET new 200; bucket 1042979316; in 1069483969; GET old 302 PASS
27 google document copy in place 1069485373 1069485375 GET new 200; bucket 1042979315; in 1069483953; source unchanged PASS
28 google document copy to other project 1069485373 1069485377 GET new 200; bucket 1042979316; in 1069483969; source unchanged PASS
29 folder move within project 1069485379 1069485380 GET new 200; bucket 1042979315; in 1069484030; GET old 302 PASS
30 folder move to other project 1069485382 1069485383 GET new 200; bucket 1042979316; in 1069483969; GET old 302 PASS
31 folder copy in place 1069485385 1069485386 GET new 200; bucket 1042979315; in 1069483953; source unchanged PASS
32 folder copy to other project 1069485385 1069485387 GET new 200; bucket 1042979316; in 1069483969; source unchanged PASS
33 schedule entry move within project 1069485388 1069485390 GET new 200; bucket 1042979315; in 1069484023; GET old 302 PASS
34 schedule entry move to other project 1069485393 1069485395 GET new 200; bucket 1042979316; in 1069483970; GET old 302 PASS
35 schedule entry copy in place 1069485398 1069485400 GET new 200; bucket 1042979315; in 1069483954; source unchanged PASS
36 schedule entry copy to other project 1069485398 1069485402 GET new 200; bucket 1042979316; in 1069483970; source unchanged PASS
37 check-in question move within project 1069485404 1069485406 GET new 200; bucket 1042979315; in 1069484024; GET old 302 PASS
38 check-in question move to other project 1069485409 1069485411 GET new 200; bucket 1042979316; in 1069483978; GET old 302 PASS
39 check-in question copy in place 1069485414 1069485416 GET new 200; bucket 1042979315; in 1069483962; source unchanged PASS
40 check-in question copy to other project 1069485414 1069485418 GET new 200; bucket 1042979316; in 1069483978; source unchanged PASS
41 email forward move within project 1069485420 1069485423 GET new 200; bucket 1042979315; in 1069484025; GET old 302 PASS
42 email forward move to other project 1069485426 1069485429 GET new 200; bucket 1042979316; in 1069483979; GET old 302 PASS
43 email forward copy in place 1069485432 1069485435 GET new 200; bucket 1042979315; in 1069483963; source unchanged PASS
44 email forward copy to other project 1069485432 1069485438 GET new 200; bucket 1042979316; in 1069483979; source unchanged PASS
45 to-do list move within project 1069485441 1069485442 GET new 200; bucket 1042979315; in 1069484026; GET old 302 PASS
46 to-do list move to other project 1069485443 1069485444 GET new 200; bucket 1042979317; in 1069484003; GET old 302 PASS
47 to-do list copy in place 1069485445 1069485446 GET new 200; bucket 1042979315; in 1069483952; source unchanged PASS
48 to-do list copy to other project 1069485445 1069485447 GET new 200; bucket 1042979317; in 1069484003; source unchanged PASS
49 to-do list group move within project 1069485448 1069485449 GET new 200; bucket 1042979315; in 1069484028; GET old 302 PASS
50 to-do list group move to other project 1069485450 1069485451 GET new 200; bucket 1042979316; in 1069484029; GET old 302 PASS
51 to-do list group copy in place 1069485452 1069485453 GET new 200; bucket 1042979315; in 1069484027; source unchanged PASS
52 to-do list group copy to other project 1069485452 1069485454 GET new 200; bucket 1042979316; in 1069484029; source unchanged PASS
53 to-do move within project 1069485455 1069485457 GET new 200; bucket 1042979315; in 1069484028; GET old 302 PASS
54 to-do move to other project 1069485459 1069485462 GET new 200; bucket 1042979316; in 1069484029; GET old 302 PASS
55 to-do copy in place 1069485465 1069485467 GET new 200; bucket 1042979315; in 1069484027; source unchanged PASS
56 to-do copy to other project 1069485465 1069485469 GET new 200; bucket 1042979316; in 1069484029; source unchanged PASS
57 document move into folder in other project 1069485471 1069485473 GET new 200; bucket 1042979316; in 1069484031; GET old 302 PASS
58 message copy with include_comments 1069485475 1069485480 GET new 200; bucket 1042979316; in 1069483967; source unchanged; copy has 1 comment(s) PASS
59 card refusal: project whose card table is off 1069485483 error.type destination_disabled; retry_safe True; source unchanged; tool_id 1069484007 PASS
60 message refusal: project with two message boards 1069485484 error.type destination_ambiguous; retry_safe True; source unchanged; 2 candidates PASS
61 document refusal: a folder named as the container 1069485485 error.type filing_refused; retry_safe True; source unchanged PASS
62 recurring event move to other project 1069485489 1069485492 answer names series 1069485492 (occurrence 1069485493); GET new follows 302 to series 1069485492 in bucket 1042979316; GET old follows 302 to the same series in 1042979316 PASS
63 recurring event copy to other project 1069485495 1069485498 answer names series 1069485498; GET new: series 1069485498 in bucket 1042979316; source: series 1069485495 still in bucket 1042979315 PASS
64 document retry a completed move with the old id (fresh document) 1069485503 first call moved it; second call error.type recording_moved, nothing filed PASS
mcp.py
"""Drive `basecamp mcp` over stdio as an MCP client: one tools/call per invocation.
usage: mcp.py BINARY TOOL JSON_ARGS [extra server args...]  -> prints {"isError":..,"text":..}"""
import json, subprocess, sys
binary, tool, args = sys.argv[1], sys.argv[2], json.loads(sys.argv[3])
proc = subprocess.Popen([binary, "mcp", *sys.argv[4:]], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
def send(msg):
    proc.stdin.write(json.dumps(msg) + "\n"); proc.stdin.flush()
def recv(want):
    while True:
        line = proc.stdout.readline()
        if not line:
            raise SystemExit("server closed stdout")
        msg = json.loads(line)
        if msg.get("id") == want:
            return msg
send({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "parity-matrix", "version": "1"}}})
recv(1)
send({"jsonrpc": "2.0", "method": "notifications/initialized"})
if tool == "tools/list":
    send({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
    print(json.dumps(recv(2)["result"]))
else:
    send({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": tool, "arguments": args}})
    r = recv(2)
    if "error" in r:
        print(json.dumps({"isError": True, "text": json.dumps(r["error"])}))
    else:
        res = r["result"]
        print(json.dumps({"isError": bool(res.get("isError")), "text": "".join(c.get("text", "") for c in res.get("content", []))}))
proc.stdin.close(); proc.wait(timeout=10)
bc.py
"""Tiny Basecamp API client for the parity matrix. Token comes from BASECAMP_TOKEN (env.sh)."""
import json
import os
import urllib.error
import urllib.request

BASE = os.environ["BASECAMP_BASE_URL"]
ACCOUNT = os.environ["BASECAMP_ACCOUNT_ID"]


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *args, **kwargs):
        return None


_opener = urllib.request.build_opener(NoRedirect)


def call(method, path, body=None):
    """Returns (status, decoded JSON or None). Never follows redirects."""
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(f"{BASE}/{ACCOUNT}{path}", data=data, method=method)
    req.add_header("Authorization", "Bearer " + os.environ["BASECAMP_TOKEN"])
    req.add_header("Accept", "application/json")
    if data is not None:
        req.add_header("Content-Type", "application/json")
    try:
        with _opener.open(req, timeout=30) as resp:
            raw = resp.read()
            status = resp.status
    except urllib.error.HTTPError as e:
        raw, status = e.read(), e.code
    try:
        return status, json.loads(raw) if raw else None
    except ValueError:
        return status, None


def ok(method, path, body=None):
    status, decoded = call(method, path, body)
    if status >= 300:
        raise SystemExit(f"{method} {path} -> {status}: {decoded}")
    return decoded


# The typed GET path of each recording type, as the SDK's model names it.
GET_PATHS = {
    "Kanban::Card": "/card_tables/cards/{}",
    "Kanban::Column": "/card_tables/columns/{}",
    "Message": "/messages/{}",
    "Document": "/documents/{}",
    "Upload": "/uploads/{}",
    "CloudFile": "/cloud_files/{}",
    "GoogleDocument": "/google_documents/{}",
    "Vault": "/vaults/{}",
    "Schedule::Entry": "/schedule_entries/{}",
    "Question": "/questions/{}",
    "Inbox::Forward": "/inbox_forwards/{}",
    "Todolist": "/todolists/{}",
    "Todo": "/todos/{}",
}


def get_recording(typ, rid):
    return call("GET", GET_PATHS[typ].format(rid))
setup.py
"""Prepare the MCP CLI Lab projects for the parity matrix and write fixtures.json.

A is the source project, with a second tool of every kind for moves within it.
B is the other project, with one enabled tool of every kind and one to-do list.
C has its card table turned off, for the refusal row.
Seeds for the kinds the API cannot create (an upload, an email forward) are
copied into A from the baseline project with the released CLI (v0.20.1)."""
import json
import subprocess
import sys

import bc

out = sys.argv[1]
BASELINE_UPLOAD, BASELINE_FORWARD = 575562939, 1069478879


def project(name):
    for p in bc.ok("GET", "/projects.json"):
        if p["name"] == name:
            return bc.ok("GET", f"/projects/{p['id']}")
    return bc.ok("POST", "/projects.json", {"name": name})


def tools(p, name):
    return [t for t in p["dock"] if t["name"] == name]


A, B, C = project("MCP CLI Lab A"), project("MCP CLI Lab B"), project("MCP CLI Lab C")
for p in (A, B):
    for t in p["dock"]:
        if t["name"] in ("kanban_board", "questionnaire", "inbox") and not t["enabled"]:
            bc.ok("POST", f"/recordings/{t['id']}/position.json")
SECOND = {"message_board": "Message::Board", "kanban_board": "Kanban::Board", "schedule": "Schedule",
          "questionnaire": "Questionnaire", "inbox": "Inbox", "todoset": "Todoset"}
A = bc.ok("GET", f"/projects/{A['id']}")
for name, tool_type in SECOND.items():
    if len(tools(A, name)) < 2:
        bc.ok("POST", f"/buckets/{A['id']}/dock/tools.json", {"tool_type": tool_type, "title": f"Second {name}"})
A, B, C = (bc.ok("GET", f"/projects/{p['id']}") for p in (A, B, C))
assert not any(t["enabled"] for t in tools(C, "kanban_board")), "C's card table must stay off"


def pair(p, name):
    found = [t["id"] for t in tools(p, name) if t["enabled"]]
    return found


fx = {"A": A["id"], "B": B["id"], "C": C["id"], "a": {}, "b": {}}
for name in ("message_board", "kanban_board", "vault", "schedule", "questionnaire", "inbox", "todoset"):
    fx["a"][name] = pair(A, name)
    fx["b"][name] = pair(B, name)[0]
    assert len(fx["a"][name]) >= 1 + (name != "vault"), (name, fx["a"][name])

# to-do lists: two in A's first to-do set, exactly one in B
lists_a = bc.ok("GET", f"/todosets/{fx['a']['todoset'][0]}/todolists.json")
while len(lists_a) < 2:
    lists_a.append(bc.ok("POST", f"/todosets/{fx['a']['todoset'][0]}/todolists.json", {"name": f"A list {len(lists_a) + 1}"}))
fx["a"]["todolists"] = [l["id"] for l in lists_a[:2]]
lists_b = bc.ok("GET", f"/todosets/{fx['b']['todoset']}/todolists.json")
if not lists_b:
    lists_b = [bc.ok("POST", f"/todosets/{fx['b']['todoset']}/todolists.json", {"name": "B list"})]
assert len(lists_b) == 1, "B needs exactly one to-do list"
fx["b"]["todolist"] = lists_b[0]["id"]

# folders: one in A's Docs & Files, one in B's
for side, vault in (("a", fx["a"]["vault"][0]), ("b", fx["b"]["vault"])):
    folders = [v for v in bc.ok("GET", f"/vaults/{vault}/vaults.json") if v["title"] == "Matrix folder"]
    folder = folders[0] if folders else bc.ok("POST", f"/vaults/{vault}/vaults.json", {"title": "Matrix folder"})
    fx[side]["folder"] = folder["id"]

# card tables: the first column you added in each of A's card tables, for cards
for i, table in enumerate(fx["a"]["kanban_board"]):
    t = bc.ok("GET", f"/card_tables/{table}")
    fx["a"].setdefault("triage", []).append(next(l["id"] for l in t["lists"] if l["type"] == "Kanban::Triage"))


def cli_copy(group, source, *flags):
    res = subprocess.run(["basecamp", group, "copy", str(source), *flags, "--json"],
                         capture_output=True, text=True, timeout=120)
    if res.returncode != 0:
        raise SystemExit(f"seed copy failed: {res.stdout} {res.stderr}")
    return json.loads(res.stdout)["data"]["id"]


fx["seed_upload"] = cli_copy("files", BASELINE_UPLOAD, "--to-project", str(A["id"]))
fx["seed_forward"] = cli_copy("forwards", BASELINE_FORWARD, "--to-inbox", str(fx["a"]["inbox"][0]))
json.dump(fx, open(out, "w"), indent=1)
print(json.dumps(fx))
matrix.py
"""Drive every move/copy row through `basecamp mcp` (stdio, tools/call basecamp_recordings)
and prove each with independent API reads. usage: matrix.py BINARY fixtures.json out-prefix"""
import json
import subprocess
import sys
import time

import bc

binary, fx, out = sys.argv[1], json.load(open(sys.argv[2])), sys.argv[3]
a, b = fx["a"], fx["b"]
stamp = time.strftime("%H%M%S")
MCP = sys.path[0] + "/mcp.py"


def mcp(action, params):
    res = subprocess.run([sys.executable, "-I", MCP, binary, "basecamp_recordings", json.dumps({"action": action, "params": params})],
                         capture_output=True, text=True, timeout=180)
    if res.returncode != 0:
        raise SystemExit(f"mcp driver failed: {res.stderr[-2000:]}")
    answer = json.loads(res.stdout)
    try:
        answer["json"] = json.loads(answer["text"])
    except ValueError:
        answer["json"] = None
    return answer


def cli_copy(group, source, *flags):
    res = subprocess.run(["basecamp", group, "copy", str(source), *flags, "--json"], capture_output=True, text=True, timeout=120)
    if res.returncode != 0:
        raise SystemExit(f"seed copy failed: {res.stdout} {res.stderr}")
    return json.loads(res.stdout)["data"]["id"]


def title(kind):
    return f"matrix {kind} {stamp} {time.time_ns() % 100000}"


SCHEDULE = {"frequency": "every_day", "days": [1, 2, 3, 4, 5], "time_of_day": "9:00am"}

# B keeps exactly one to-do list, so a to-do or group sent to project B has one place to go;
# to-do lists themselves go to project C, whose to-do set takes any number.
for extra in bc.ok("GET", f"/todosets/{b['todoset']}/todolists.json"):
    if extra["id"] != b["todolist"]:
        bc.ok("PUT", f"/recordings/{extra['id']}/status/trashed.json")
c_todoset = next(t["id"] for t in bc.ok("GET", f"/projects/{fx['C']}")["dock"] if t["name"] == "todoset" and t["enabled"])

# kind: (recording type, create a source in A, {row: (params, expected project, expected parent)})
KINDS = {
    "card": ("Kanban::Card", lambda: bc.ok("POST", f"/card_tables/lists/{a['triage'][0]}/cards.json", {"title": title("card")})["id"],
             a["kanban_board"], b["kanban_board"], None),
    "column": ("Kanban::Column", lambda: bc.ok("POST", f"/card_tables/{a['kanban_board'][0]}/columns.json", {"title": title("column")})["id"],
               a["kanban_board"], b["kanban_board"], None),
    "message": ("Message", lambda: bc.ok("POST", f"/message_boards/{a['message_board'][0]}/messages.json", {"subject": title("message"), "status": "active"})["id"],
                a["message_board"], b["message_board"], None),
    "document": ("Document", lambda: bc.ok("POST", f"/vaults/{a['vault'][0]}/documents.json", {"title": title("document"), "content": "x", "status": "active"})["id"],
                 a["vault"], b["vault"], a["folder"]),
    "upload": ("Upload", lambda: cli_copy("files", fx["seed_upload"], "--to-project", str(fx["A"])),
               a["vault"], b["vault"], a["folder"]),
    "cloud file": ("CloudFile", lambda: bc.ok("POST", f"/buckets/{fx['A']}/vaults/{a['vault'][0]}/cloud_files.json", {"title": title("cloud"), "url": "https://www.dropbox.com/s/abc/matrix.pdf", "service": "dropbox"})["id"],
                   a["vault"], b["vault"], a["folder"]),
    "google document": ("GoogleDocument", lambda: bc.ok("POST", f"/buckets/{fx['A']}/vaults/{a['vault'][0]}/google_documents.json", {"title": title("gdoc"), "url": "https://docs.google.com/document/d/matrix/edit", "document_type": "doc", "status": "active"})["id"],
                        a["vault"], b["vault"], a["folder"]),
    "folder": ("Vault", lambda: bc.ok("POST", f"/vaults/{a['vault'][0]}/vaults.json", {"title": title("folder")})["id"],
               a["vault"], b["vault"], a["folder"]),
    "schedule entry": ("Schedule::Entry", lambda: bc.ok("POST", f"/schedules/{a['schedule'][0]}/entries.json", {"summary": title("entry"), "starts_at": "2026-11-20T15:00:00Z", "ends_at": "2026-11-20T16:00:00Z"})["id"],
                       a["schedule"], b["schedule"], None),
    "check-in question": ("Question", lambda: bc.ok("POST", f"/questionnaires/{a['questionnaire'][0]}/questions.json", {"title": title("question"), "schedule": SCHEDULE})["id"],
                          a["questionnaire"], b["questionnaire"], None),
    "email forward": ("Inbox::Forward", lambda: cli_copy("forwards", fx["seed_forward"], "--to-inbox", str(a["inbox"][0])),
                      a["inbox"], b["inbox"], None),
    "to-do list": ("Todolist", lambda: bc.ok("POST", f"/todosets/{a['todoset'][0]}/todolists.json", {"name": title("list")})["id"],
                   a["todoset"], c_todoset, None, "C"),
    "to-do list group": ("Todolist", lambda: bc.ok("POST", f"/todolists/{a['todolists'][0]}/groups.json", {"name": title("group")})["id"],
                         a["todolists"], b["todolist"], None),
    "to-do": ("Todo", lambda: bc.ok("POST", f"/todolists/{a['todolists'][0]}/todos.json", {"content": title("todo")})["id"],
              a["todolists"], b["todolist"], None),
}


def landed_in(typ, rec):
    """The container a recording sits in: a card's card table is its column's parent."""
    parent = rec.get("parent") or {}
    if typ == "Kanban::Card":
        _, column = bc.get_recording("Kanban::Column", parent["id"])
        return (column or {}).get("parent", {}).get("id")
    return parent.get("id")


rows = []


def record(kind, op, src, answer, checks, verdict, note=""):
    new = (answer.get("json") or {}).get("id") if answer and not answer["isError"] else ""
    rows.append({"kind": kind, "op": op, "src": src, "new": new, "mcp": "ok" if answer and not answer["isError"] else (answer or {}).get("text", "")[:160],
                 "checks": "; ".join(checks), "verdict": verdict, "note": note})
    print(f"{verdict:5} {kind:18} {op:22} src={src} new={new} {'; '.join(checks)} {note}", flush=True)


def run_row(kind, op, typ, src, params, project, container):
    before_status, before = bc.get_recording(typ, src)
    answer = mcp("copy" if op.startswith("copy") else "move", dict(params, recording_id=src, recording_type=typ))
    checks = []
    if answer["isError"] or not answer["json"]:
        record(kind, op, src, answer, ["mcp error"], "FAIL")
        return
    rec = answer["json"]
    status, fresh = bc.get_recording(typ, rec["id"])
    checks.append(f"GET new {status}")
    good = status == 200 and rec.get("source_id") == src and fresh["id"] != src
    good &= fresh.get("bucket", {}).get("id") == project
    checks.append(f"bucket {fresh.get('bucket', {}).get('id')}")
    where = landed_in(typ, fresh)
    checks.append(f"in {where}")
    good &= where == container
    old_status, old = bc.get_recording(typ, src)
    if op.startswith("move"):
        checks.append(f"GET old {old_status}")
        good &= old_status in (302, 404)
    else:
        unchanged = old_status == 200 and old["id"] == src and old.get("bucket") == before.get("bucket") and old.get("parent", {}).get("id") == before.get("parent", {}).get("id")
        checks.append(f"source {'unchanged' if unchanged else 'CHANGED'}")
        good &= unchanged
    record(kind, op, src, answer, checks, "PASS" if good else "FAIL")
    return rec


for kind, (typ, create, mine, theirs, folder, *other) in KINDS.items():
    home, other_container = mine[0], theirs
    other_project = fx[other[0]] if other else fx["B"]
    within = {"container_id": mine[1]} if folder is None else {"container_id": home, "folder_id": folder}
    within_container = mine[1] if folder is None else folder
    try:
        run_row(kind, "move within project", typ, create(), within, fx["A"], within_container)
        run_row(kind, "move to other project", typ, create(), {"project_id": other_project}, other_project, other_container)
        src = create()
        run_row(kind, "copy in place", typ, src, {"container_id": home}, fx["A"], home)
        run_row(kind, "copy to other project", typ, src, {"project_id": other_project}, other_project, other_container)
    except SystemExit as e:
        record(kind, "setup", "", None, [str(e)[:200]], "ERROR")

# folder placement in another project, and comments carried by a copy
src = KINDS["document"][1]()
run_row("document", "move into folder in other project", "Document", src, {"project_id": fx["B"], "folder_id": b["folder"]}, fx["B"], b["folder"])
src = KINDS["message"][1]()
bc.ok("POST", f"/recordings/{src}/comments.json", {"content": "carried along"})
rec = run_row("message", "copy with include_comments", "Message", src, {"project_id": fx["B"], "include_comments": True}, fx["B"], b["message_board"])
if rec:
    _, comments = bc.call("GET", f"/recordings/{rec['id']}/comments.json")
    rows[-1]["checks"] += f"; copy has {len(comments or [])} comment(s)"
    if len(comments or []) != 1:
        rows[-1]["verdict"] = "FAIL"
    print("  comments on copy:", len(comments or []))


def refusal(kind, typ, params, want_type, note):
    src = KINDS[kind][1]()
    before_status, before = bc.get_recording(typ, src)
    answer = mcp("move", dict(params, recording_id=src, recording_type=typ))
    err = (answer.get("json") or {}).get("error") or {}
    after_status, after = bc.get_recording(typ, src)
    unchanged = after_status == 200 and after["id"] == src and after.get("parent", {}).get("id") == before.get("parent", {}).get("id")
    checks = [f"error.type {err.get('type')}", f"retry_safe {err.get('retry_safe')}", f"source {'unchanged' if unchanged else 'CHANGED'}"]
    if err.get("candidates"):
        checks.append(f"{len(err['candidates'])} candidates")
    if err.get("tool_id"):
        checks.append(f"tool_id {err['tool_id']}")
    ok = answer["isError"] and err.get("type") == want_type and unchanged
    rows.append({"kind": kind, "op": f"refusal: {note}", "src": src, "new": "", "mcp": err.get("message", answer["text"])[:200],
                 "checks": "; ".join(checks), "verdict": "PASS" if ok else "FAIL", "note": ""})
    print(f"{'PASS' if ok else 'FAIL':5} {kind:18} refusal {note}: {'; '.join(checks)} :: {err.get('message', answer['text'])[:200]}", flush=True)


refusal("card", "Kanban::Card", {"project_id": fx["C"]}, "destination_disabled", "project whose card table is off")
refusal("message", "Message", {"project_id": fx["A"]}, "destination_ambiguous", "project with two message boards")
refusal("document", "Document", {"container_id": a["folder"]}, "filing_refused", "a folder named as the container")

json.dump(rows, open(out + ".json", "w"), indent=1)
with open(out + ".md", "w") as f:
    f.write("| # | kind | operation | source | new | API checks | verdict |\n|---|---|---|---|---|---|---|\n")
    for i, r in enumerate(rows, 1):
        f.write(f"| {i} | {r['kind']} | {r['op']} | {r['src']} | {r['new']} | {r['checks']} | {r['verdict']} |\n")
counts = {}
for r in rows:
    counts[r["verdict"]] = counts.get(r["verdict"], 0) + 1
print("COUNTS", counts, "of", len(rows))

@github-actions github-actions Bot added sdk SDK wrapper and provenance tests Tests (unit and e2e) docs deps labels Oct 28, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Preflight failures omit error classification and retry-safety fields, and recurring schedule series are rejected because occurrence reads are not normalized to the series.

2 open issues
What changed in this PR

Adds waiting move and copy actions to the MCP recordings tool, bringing its filing workflow in line with the CLI.

Changes:

  • Replaces start-only actions with move and copy, returning the new recording and reporting filing status and retry safety.
  • Moves get_filing into basecamp_recordings and adds routing, failure-handling, and read-only tests.
  • Updates the Basecamp SDK to v0.32.0 and refreshes dependency and model provenance.

[!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.

File Description
README.md Documents waiting move and copy actions.
nix/​package.nix Updates the dependency vendor hash.
internal/​version/​sdk-provenance.json Records SDK v0.32.0 provenance.
internal/​mcpserver/​testdata/​catalog_snapshot.txt Updates advertised actions and their domains.
internal/​mcpserver/​model/​PROVENANCE.json Updates vendored model provenance.
internal/​mcpserver/​filing.go Implements filing actions, destination resolution, and error reporting.
internal/​mcpserver/​filing_test.go Tests supported types, destinations, failures, and read-only behavior.
internal/​mcpserver/​dispatch.go Adds typed SDK services needed for filing.
internal/​mcpserver/​composite.go Registers filing actions and shares parameter validation.
internal/​mcpserver/​catalog_test.go Updates operation counts and replacement assertions.
go.sum Updates SDK checksums.
go.mod Bumps the SDK dependency to v0.32.0.

Review effort: Balanced

Comment thread internal/mcpserver/filing.go Outdated
Comment thread internal/mcpserver/filing.go
…iling

Basecamp answers a recurring event's read with one occurrence, so the type check refused every recurring event. The read now names the series, with the occurrence kept as occurrence_id, as the CLI's own schedule move does.

A failed read of the recording or the destination project now answers with error.type (recording_unreadable, destination_unreadable) and retry_safe, like every other failure after the arguments are accepted.
A moved recording's old id reads as its new one. Filing what that read found would move the recording again when a lost answer to a move is retried. The call is now refused as recording_moved, naming the new id, and nothing is filed.
Copilot AI balanced review requested due to automatic review settings October 28, 2026 20:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The copy-action documentation returned by describe incorrectly says “Copys a recording” instead of “Copy a recording.”

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

Low severity Fix incorrect “Copys” spelling in describe documentation

internal/​mcpserver/​filing.go:164

Minor spelling fix: for the copy action, verb + "s" produces “Copys a recording” in the documentation returned by describe. Using the imperative form (“Move a recording” / “Copy a recording”) avoids the incorrect inflection.

2 resolved since last review

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 28, 2026 20:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approved

No active review issues remain for the MCP recording move and copy changes.

No open issues 🎉

Review effort: Balanced

@robzolkos

Copy link
Copy Markdown
Collaborator Author

Review summary for head 5637b0d.

  • Copilot, recurring events read as an occurrence: fixed in a739380 (thread resolved).
  • Copilot, untyped failures from reads before filing: fixed in a739380 (thread resolved).
  • Copilot, "Copys" in the copy action's describe doc (review body, no thread): fixed in 5637b0d.
  • Independent review (gpt-6.1-sol): found that a to-do's ambiguity listed To-dos tools instead of lists (fixed before opening), and that a retried move could file a redirected recording again (fixed in e846a39 as recording_moved). The last two rounds reported no findings.

CI is green and Copilot approved this head. No thread is open.

@robzolkos
robzolkos merged commit 4b30715 into main Oct 28, 2026
42 checks passed
@robzolkos
robzolkos deleted the mcp-filing-wait branch October 28, 2026 20:40
@robzolkos robzolkos mentioned this pull request Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deps docs sdk SDK wrapper and provenance tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants