From 57b755f2c97a871ac45b75ab9a3202c5d9cbca2e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 28 Aug 2026 04:35:15 -0700 Subject: [PATCH 1/3] Serve Fizzy over MCP with fizzy mcp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fizzy mcp runs an MCP server on stdin/stdout, serving Fizzy boards, cards, comments, steps, tags, and users as domain gateway tools backed by the signed-in account. The CLI assembles the same shape as fizzy-mcp-server from the public basecamp/mcp toolkit (gateway, mcptest) plus a duplicated copy of the server's hand-written catalog — synced by scripts/sync-mcp-catalog.sh with provenance recorded — and dispatches through the CLI's authenticated, account-scoped fizzy-sdk client. Read-only by default, matching the server's posture; --writes opts in (pair with a Read+Write token) and --domains narrows the surface, failing closed on unknown keys. Paginated listings surface the Link rel=next page number as next_page; 201 Locations are followed so create actions answer with the created resource. --- AGENTS.md | 1 + README.md | 20 + SURFACE.txt | 18 + go.mod | 12 +- go.sum | 40 +- internal/commands/help.go | 2 +- internal/commands/mcp.go | 73 + internal/commands/mcp_test.go | 213 ++ internal/mcpserver/catalog/PROVENANCE.json | 7 + internal/mcpserver/catalog/catalog.go | 299 +++ internal/mcpserver/catalog/catalog_test.go | 219 +++ internal/mcpserver/catalog/domains.go | 401 ++++ .../catalog/testdata/catalog_snapshot.txt | 1707 +++++++++++++++++ internal/mcpserver/dispatch.go | 349 ++++ internal/mcpserver/dispatch_test.go | 214 +++ internal/mcpserver/server.go | 91 + internal/mcpserver/server_test.go | 318 +++ scripts/sync-mcp-catalog.sh | 46 + 18 files changed, 4018 insertions(+), 12 deletions(-) create mode 100644 internal/commands/mcp.go create mode 100644 internal/commands/mcp_test.go create mode 100644 internal/mcpserver/catalog/PROVENANCE.json create mode 100644 internal/mcpserver/catalog/catalog.go create mode 100644 internal/mcpserver/catalog/catalog_test.go create mode 100644 internal/mcpserver/catalog/domains.go create mode 100644 internal/mcpserver/catalog/testdata/catalog_snapshot.txt create mode 100644 internal/mcpserver/dispatch.go create mode 100644 internal/mcpserver/dispatch_test.go create mode 100644 internal/mcpserver/server.go create mode 100644 internal/mcpserver/server_test.go create mode 100755 scripts/sync-mcp-catalog.sh diff --git a/AGENTS.md b/AGENTS.md index f6292e1..a7a74cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ fizzy-cli/ │ ├── commands/ # Command implementations │ ├── config/ # Configuration management │ ├── errors/ # Error handling and types +│ ├── mcpserver/ # `fizzy mcp` MCP server (catalog/ synced from fizzy-mcp-server) │ └── render/ # Output rendering (styled, markdown, columns) ├── e2e/ # Go integration tests ├── skills/ # Agent skills diff --git a/README.md b/README.md index 775d822..61c5f8c 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,26 @@ fizzy config explain fizzy config explain --profile acme ``` +## MCP server + +`fizzy mcp` runs an MCP (Model Context Protocol) server on stdin/stdout, serving Fizzy +boards, cards, comments, steps, tags, and users as tools backed by your signed-in +account — the same credentials every other command uses. Register it with any MCP +client as a stdio server: + +```bash +claude mcp add fizzy -- fizzy mcp # Claude Code +fizzy mcp --writes # serve write actions too (pair with a Read+Write token) +fizzy mcp --domains boards,cards # narrow the served tool surface +``` + +Each domain is one gateway tool (`fizzy_identity`, `fizzy_boards`, `fizzy_columns`, +`fizzy_cards`, `fizzy_comments`, `fizzy_steps`, `fizzy_tags`, `fizzy_users`); call an +action named `describe` for any action's parameter schema. Read-only by default — +`--writes` opts in. Listings with more pages come back as +`{"data": ..., "next_page": N}` — pass the number back as the action's `page` +parameter. Logs go to stderr — stdout carries the MCP wire protocol. + ## Troubleshooting ```bash diff --git a/SURFACE.txt b/SURFACE.txt index 3314847..9a42555 100644 --- a/SURFACE.txt +++ b/SURFACE.txt @@ -146,6 +146,7 @@ CMD fizzy identity help CMD fizzy identity show CMD fizzy identity timezone-update CMD fizzy identity view +CMD fizzy mcp CMD fizzy migrate CMD fizzy migrate board CMD fizzy migrate help @@ -2010,6 +2011,22 @@ FLAG fizzy identity view --quiet type=bool FLAG fizzy identity view --styled type=bool FLAG fizzy identity view --token type=string FLAG fizzy identity view --verbose type=bool +FLAG fizzy mcp --agent type=bool +FLAG fizzy mcp --api-url type=string +FLAG fizzy mcp --count type=bool +FLAG fizzy mcp --domains type=stringSlice +FLAG fizzy mcp --help type=bool +FLAG fizzy mcp --ids-only type=bool +FLAG fizzy mcp --jq type=string +FLAG fizzy mcp --json type=bool +FLAG fizzy mcp --limit type=int +FLAG fizzy mcp --markdown type=bool +FLAG fizzy mcp --profile type=string +FLAG fizzy mcp --quiet type=bool +FLAG fizzy mcp --styled type=bool +FLAG fizzy mcp --token type=string +FLAG fizzy mcp --verbose type=bool +FLAG fizzy mcp --writes type=bool FLAG fizzy migrate --agent type=bool FLAG fizzy migrate --api-url type=string FLAG fizzy migrate --count type=bool @@ -3454,6 +3471,7 @@ SUB fizzy identity help SUB fizzy identity show SUB fizzy identity timezone-update SUB fizzy identity view +SUB fizzy mcp SUB fizzy migrate SUB fizzy migrate board SUB fizzy migrate help diff --git a/go.mod b/go.mod index bf20ad3..4c5b843 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,14 @@ go 1.26.5 require ( github.com/basecamp/cli v0.2.1 github.com/basecamp/fizzy-sdk/go v0.2.4 + github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/term v0.2.2 github.com/hashicorp/go-version v1.9.0 github.com/itchyny/gojq v0.12.19 github.com/mattn/go-isatty v0.0.24 + github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/muesli/termenv v0.16.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -35,6 +37,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/itchyny/timefmt-go v0.1.8 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect @@ -44,8 +47,15 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/stretchr/testify v1.12.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.19.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.15.0 // indirect ) diff --git a/go.sum b/go.sum index 842a4c5..9d6bf44 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/basecamp/cli v0.2.1 h1:8GyehPVtsTXla0oOPu4QgXRjwwzJ99prlByvyi+0HRQ= github.com/basecamp/cli v0.2.1/go.mod h1:p8tt/DatJ2LAzWO6N6tNfV8x3gF5T3IxDTo+U8FfWPo= github.com/basecamp/fizzy-sdk/go v0.2.4 h1:eTkLA/C9Pr72kS0ld30oIHftxw7LvKRJAV/543b7GDk= github.com/basecamp/fizzy-sdk/go v0.2.4/go.mod h1:xut9OSlqnbsHrdLiBL3YWJHeGAUC9Q+i0eJyGGwLq44= +github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4= +github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d/go.mod h1:Ee2c/q1/pg+5T5741PIuA3s6VJMQC7I0XBNXIHIujzA= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= @@ -49,14 +51,18 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -75,42 +81,56 @@ github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byF github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/commands/help.go b/internal/commands/help.go index 263ed44..f34fc2e 100644 --- a/internal/commands/help.go +++ b/internal/commands/help.go @@ -382,7 +382,7 @@ var rootCommandGroups = map[string][]string{ "core": {"auth", "token", "activity", "board", "card", "search"}, "collaboration": {"comment", "notification"}, "getting-started": {"setup", "signup"}, - "discover": {"doctor", "config", "commands", "version"}, + "discover": {"doctor", "config", "commands", "mcp", "version"}, } var commandExamples = map[string]string{ diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go new file mode 100644 index 0000000..19f5d21 --- /dev/null +++ b/internal/commands/mcp.go @@ -0,0 +1,73 @@ +package commands + +import ( + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/cobra" + + "github.com/basecamp/fizzy-cli/internal/mcpserver" +) + +// mcpTransport is a seam so tests can drive the server over in-memory +// transports instead of the process's stdin/stdout. +var mcpTransport = func() mcp.Transport { return &mcp.StdioTransport{} } + +var ( + mcpWrites bool + mcpDomains []string +) + +var mcpCmd = &cobra.Command{ + Use: "mcp", + Short: "Serve Fizzy to MCP clients over stdio", + Long: "Run an MCP (Model Context Protocol) server on stdin/stdout, serving Fizzy\n" + + "boards, cards, comments, steps, tags, and users as tools backed by your\n" + + "signed-in account.\n\n" + + "Read-only by default; --writes serves write actions too (pair with a\n" + + "Read+Write access token). Register it with an MCP client as a stdio\n" + + "server, e.g.:\n\n" + + " claude mcp add fizzy -- fizzy mcp", + Args: cobra.NoArgs, + Annotations: map[string]string{ + "agent_notes": "Long-running server; stdout speaks the MCP wire protocol. Not for interactive use.", + }, + RunE: runMCP, +} + +func runMCP(cmd *cobra.Command, args []string) error { + if err := requireAuthAndAccount(); err != nil { + return err + } + + srv, err := mcpserver.New(getSDK(), getSDKClient(), mcpserver.Config{ + ReadOnly: !mcpWrites, + Domains: mcpDomains, + Version: currentVersion(), + }) + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // Log to stderr: stdout belongs to the MCP wire. + logger := slog.New(slog.NewTextHandler(cmd.ErrOrStderr(), nil)) + session, err := srv.BuildMCPServer(logger).Connect(ctx, mcpTransport(), nil) + if err != nil { + return err + } + logger.Info("MCP server running on stdio", "tools", len(srv.Domains()), "read_only", !mcpWrites) + + return session.Wait() +} + +func init() { + mcpCmd.Flags().BoolVar(&mcpWrites, "writes", false, "Serve write actions too (pair with a Read+Write access token)") + mcpCmd.Flags().StringSliceVar(&mcpDomains, "domains", nil, "Narrow to specific domains (comma-separated; default all)") + rootCmd.AddCommand(mcpCmd) +} diff --git a/internal/commands/mcp_test.go b/internal/commands/mcp_test.go new file mode 100644 index 0000000..72d75ee --- /dev/null +++ b/internal/commands/mcp_test.go @@ -0,0 +1,213 @@ +package commands + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestMCPCommandRegistration(t *testing.T) { + cmd, _, err := rootCmd.Find([]string{"mcp"}) + if err != nil || cmd.Name() != "mcp" { + t.Fatalf("mcp command not registered: %v", err) + } + + writes := cmd.Flags().Lookup("writes") + if writes == nil || writes.DefValue != "false" { + t.Errorf("writes flag = %#v, want present and defaulting off (read-only is the served default)", writes) + } + if cmd.Flags().Lookup("domains") == nil { + t.Error("domains flag missing") + } +} + +func TestMCPCommandRequiresAuth(t *testing.T) { + resetMCPFlags(t) + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestConfig("", "", "https://api.example.com") + defer resetTest() + + rootCmd.SetArgs([]string{"mcp"}) + err := rootCmd.Execute() + if err == nil || !strings.Contains(err.Error(), "No API token configured") { + t.Fatalf("err = %v, want auth error", err) + } +} + +// resetMCPFlags clears the mcp flag variables: cobra's Var flags +// accumulate across Execute calls within one process, so each test starts +// from the command's real defaults. +func resetMCPFlags(t *testing.T) { + t.Helper() + mcpWrites = false + mcpDomains = nil +} + +// stubMCPTransport swaps the stdio transport for the server side of an +// in-memory pipe and returns the client side. +func stubMCPTransport(t *testing.T) mcp.Transport { + t.Helper() + clientTransport, serverTransport := mcp.NewInMemoryTransports() + orig := mcpTransport + mcpTransport = func() mcp.Transport { return serverTransport } + t.Cleanup(func() { mcpTransport = orig }) + return clientTransport +} + +// runMCPCommand runs `fizzy mcp` against a stub Fizzy upstream and +// connects a real MCP client to it over the transport seam. The command +// exits when the client session closes. +func runMCPCommand(t *testing.T, upstream *httptest.Server, args ...string) *mcp.ClientSession { + t.Helper() + + resetMCPFlags(t) + SetTestModeWithSDK(NewMockClient()) + SetTestSDK(upstream.URL) // repoint the SDK at the asserting upstream + SetTestConfig("test-token", "test-account", upstream.URL) + t.Cleanup(resetTest) + + clientTransport := stubMCPTransport(t) + + rootCmd.SetArgs(append([]string{"mcp"}, args...)) + done := make(chan error, 1) + go func() { done <- rootCmd.Execute() }() + t.Cleanup(func() { + if err := <-done; err != nil { + t.Errorf("fizzy mcp exited with error: %v", err) + } + }) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0.0.0"}, nil) + session, err := client.Connect(context.Background(), clientTransport, nil) + if err != nil { + t.Fatalf("MCP initialize failed: %v", err) + } + t.Cleanup(func() { _ = session.Close() }) + return session +} + +func TestMCPCommandServesMCP(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/test-account/boards" { + t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + // Tool calls must ride on the CLI's own credentials. + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + t.Errorf("Authorization = %q, want the CLI's token", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":"b1","name":"Launch list"}]`)) + })) + t.Cleanup(upstream.Close) + + session := runMCPCommand(t, upstream) + + if got := session.InitializeResult().ServerInfo.Name; got != "fizzy-cli" { + t.Errorf("server name = %q, want fizzy-cli", got) + } + + names := make([]string, 0, 8) + for tool, err := range session.Tools(context.Background(), nil) { + if err != nil { + t.Fatal(err) + } + names = append(names, tool.Name) + } + if len(names) != 8 { + t.Fatalf("tools = %v, want 8 fizzy_* tools", names) + } + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "fizzy_boards", + Arguments: map[string]any{"action": "list_boards", "params": map[string]any{}}, + }) + if err != nil { + t.Fatal(err) + } + if result.IsError { + t.Fatalf("list_boards failed: %v", result.Content) + } + text, ok := result.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("content = %T", result.Content[0]) + } + var boards []struct { + Name string `json:"name"` + } + if err := json.Unmarshal([]byte(text.Text), &boards); err != nil || len(boards) == 0 || boards[0].Name != "Launch list" { + t.Fatalf("list_boards result = %q (%v)", text.Text, err) + } +} + +func TestMCPCommandDefaultsToReadOnlyAndPassesDomainsThrough(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + })) + t.Cleanup(upstream.Close) + + session := runMCPCommand(t, upstream, "--domains", "cards") + + tools := make([]*mcp.Tool, 0, 1) + for tool, err := range session.Tools(context.Background(), nil) { + if err != nil { + t.Fatal(err) + } + tools = append(tools, tool) + } + if len(tools) != 1 || tools[0].Name != "fizzy_cards" { + t.Fatalf("tools = %v, want just fizzy_cards", tools) + } + if !strings.Contains(tools[0].Description, "get_card") { + t.Error("read-only fizzy_cards lost its read actions") + } + if strings.Contains(tools[0].Description, "create_card") { + t.Error("fizzy_cards lists a write action without --writes") + } +} + +func TestMCPCommandWritesOptIn(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + })) + t.Cleanup(upstream.Close) + + session := runMCPCommand(t, upstream, "--writes", "--domains", "cards") + + tools := make([]*mcp.Tool, 0, 1) + for tool, err := range session.Tools(context.Background(), nil) { + if err != nil { + t.Fatal(err) + } + tools = append(tools, tool) + } + if len(tools) != 1 || tools[0].Name != "fizzy_cards" { + t.Fatalf("tools = %v, want just fizzy_cards", tools) + } + if !strings.Contains(tools[0].Description, "create_card") { + t.Error("--writes did not serve write actions") + } +} + +func TestMCPCommandUnknownDomainFailsClosed(t *testing.T) { + resetMCPFlags(t) + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestConfig("test-token", "test-account", "https://api.example.com") + defer resetTest() + + rootCmd.SetArgs([]string{"mcp", "--domains", "bogus"}) + err := rootCmd.Execute() + if err == nil || !strings.Contains(err.Error(), `unknown domain "bogus"`) { + t.Fatalf("err = %v, want unknown domain failure", err) + } +} diff --git a/internal/mcpserver/catalog/PROVENANCE.json b/internal/mcpserver/catalog/PROVENANCE.json new file mode 100644 index 0000000..c5f66ad --- /dev/null +++ b/internal/mcpserver/catalog/PROVENANCE.json @@ -0,0 +1,7 @@ +{ + "source": "github.com/basecamp/fizzy-mcp-server", + "commit": "81f7c4a980b9eb8be1ef9d83a4c4d5d939d3afa6", + "path": "internal/catalog", + "files": ["catalog.go", "domains.go", "testdata/catalog_snapshot.txt"], + "synced_by": "scripts/sync-mcp-catalog.sh" +} diff --git a/internal/mcpserver/catalog/catalog.go b/internal/mcpserver/catalog/catalog.go new file mode 100644 index 0000000..cf37484 --- /dev/null +++ b/internal/mcpserver/catalog/catalog.go @@ -0,0 +1,299 @@ +// Package catalog hand-writes the Fizzy MCP tool catalog. +// +// Where hey-mcp-server derives its catalog from hey-sdk's model exports, +// Fizzy has no SDK model to generate from yet — so this package follows +// basecamp-mcp-server's path instead: curated, hand-maintained domain +// specs, mirroring Fizzy's public API docs (docs/api in the fizzy repo). +// The rendering contract — tool description, input schema, the describe +// payload — matches what the toolkit's catalog package generates, so the +// wire surface stays consistent across the three servers, and generation +// can replace this file the day a fizzy-sdk ships model exports. +package catalog + +import ( + "fmt" + "sort" + "strings" + + "github.com/basecamp/mcp/gateway" +) + +// Param is one path or query parameter of an operation. +type Param struct { + Name string `json:"name"` + In string `json:"in"` // "path" or "query" + Required bool `json:"required,omitempty"` + Description string `json:"description,omitempty"` + Schema map[string]any `json:"schema"` +} + +// Operation is one Fizzy API endpoint exposed as a gateway action: +// everything a gateway tool needs to list, describe, and dispatch it. +type Operation struct { + Action string `json:"action"` + Method string `json:"method"` + Path string `json:"path"` // account-scoped unless Unscoped; {name} tokens match path Params + Summary string `json:"summary"` + Doc string `json:"doc,omitempty"` + ReadOnly bool `json:"readonly"` + // Paginated marks endpoints that page via the Link header; responses + // carry next_page when more results exist, fetched by passing page. + Paginated bool `json:"paginated,omitempty"` + Params []Param `json:"params,omitempty"` + // Body is the JSON Schema of the request body's properties. The handler + // nests supplied fields under BodyKey when set ({"card": {...}}). + Body map[string]any `json:"body,omitempty"` + // BodyKey is the wrapper key Rails wraps parameters under, "" for flat. + BodyKey string `json:"body_key,omitempty"` + // Unscoped marks the few endpoints outside the /:account_slug prefix. + Unscoped bool `json:"unscoped,omitempty"` +} + +// Domain is one gateway tool: a curated group of operations exposed as a +// single MCP tool with action dispatch. +type Domain struct { + Key string // short name, e.g. "cards" + Tool string // MCP tool name, e.g. "fizzy_cards" + Blurb string // first line of the tool description + Operations []*Operation // sorted by action name +} + +var _ gateway.Domain = (*Domain)(nil) + +// Name returns the short domain key, e.g. "cards". +func (d *Domain) Name() string { return d.Key } + +// ToolName returns the MCP tool name, e.g. "fizzy_cards". +func (d *Domain) ToolName() string { return d.Tool } + +// Find returns the dispatch surface of the operation registered under the +// given action name. +func (d *Domain) Find(action string) (gateway.Operation, bool) { + op, ok := d.Operation(action) + if !ok { + return gateway.Operation{}, false + } + return gateway.Operation{Action: op.Action, ReadOnly: op.ReadOnly}, true +} + +// FilterReadOnly returns a copy of the domain containing only read-only +// operations, reporting false when none remain. +func (d *Domain) FilterReadOnly() (gateway.Domain, bool) { + filtered := &Domain{Key: d.Key, Tool: d.Tool, Blurb: d.Blurb} + for _, op := range d.Operations { + if op.ReadOnly { + filtered.Operations = append(filtered.Operations, op) + } + } + if len(filtered.Operations) == 0 { + return nil, false + } + return filtered, true +} + +// Operation returns the operation registered under the given action name. +func (d *Domain) Operation(action string) (*Operation, bool) { + for _, op := range d.Operations { + if op.Action == action { + return op, true + } + } + return nil, false +} + +// AllReadOnly reports whether every operation in the domain is read-only. +func (d *Domain) AllReadOnly() bool { + for _, op := range d.Operations { + if !op.ReadOnly { + return false + } + } + return true +} + +// Description renders the tool description: the domain blurb, the gateway +// calling convention, and a one-line summary per action. Matches the +// toolkit catalog's generated rendering. +func (d *Domain) Description() string { + var b strings.Builder + fmt.Fprintf(&b, "%s\n\n", d.Blurb) + b.WriteString("Gateway tool: call with {\"action\": \"...\", \"params\": {...}}.\n") + fmt.Fprintf(&b, "Call {\"action\": %q, \"params\": {\"action\": \"NAME\"}} for an action's full parameter schema.\n\n", gateway.DescribeAction) + b.WriteString("ACTIONS (RO = read-only):\n") + for _, op := range d.Operations { + var notes []string + if op.ReadOnly { + notes = append(notes, "RO") + } + if op.Paginated { + notes = append(notes, "paginated") + } + suffix := "" + if len(notes) > 0 { + suffix = " (" + strings.Join(notes, ", ") + ")" + } + fmt.Fprintf(&b, "- %s%s: %s\n", op.Action, suffix, op.Summary) + } + return b.String() +} + +// InputSchema renders the JSON Schema for the gateway tool's arguments. +// Per-action parameter and body schemas are served on demand via the +// describe action rather than inlined here, keeping tools/list small. +func (d *Domain) InputSchema() map[string]any { + actions := make([]any, 0, len(d.Operations)+1) + for _, op := range d.Operations { + actions = append(actions, op.Action) + } + actions = append(actions, gateway.DescribeAction) + return map[string]any{ + "type": "object", + "required": []any{"action"}, + "additionalProperties": false, + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": actions, + }, + "params": map[string]any{ + "type": "object", + "description": "Parameters for the action. Call describe for the action's schema.", + }, + }, + } +} + +// Describe returns the describe payload for one action, or for the whole +// domain when action is empty. +func (d *Domain) Describe(action string) (any, error) { + if action == "" { + summaries := make([]map[string]any, 0, len(d.Operations)) + for _, op := range d.Operations { + summaries = append(summaries, map[string]any{ + "action": op.Action, + "summary": op.Summary, + "readonly": op.ReadOnly, + }) + } + return map[string]any{"domain": d.Key, "actions": summaries}, nil + } + op, ok := d.Operation(action) + if !ok { + return nil, fmt.Errorf("unknown action %q in domain %q (actions: %s)", action, d.Key, strings.Join(d.ActionNames(), ", ")) + } + return op, nil +} + +// ActionNames returns the domain's action names in sorted order. +func (d *Domain) ActionNames() []string { + names := make([]string, 0, len(d.Operations)) + for _, op := range d.Operations { + names = append(names, op.Action) + } + sort.Strings(names) + return names +} + +// Load validates the hand-written domains and returns them ready for the +// gateway. Curation mistakes — duplicate actions, path tokens without a +// matching param, a query param named like a path token — fail here, at +// startup, not at dispatch time. +func Load() ([]*Domain, error) { + seenKeys := map[string]bool{} + for _, d := range Domains { + if seenKeys[d.Key] { + return nil, fmt.Errorf("duplicate domain key %q", d.Key) + } + seenKeys[d.Key] = true + if err := validate(d); err != nil { + return nil, fmt.Errorf("domain %q: %w", d.Key, err) + } + } + return Domains, nil +} + +// GatewayDomains adapts the catalog for gateway.New. +func GatewayDomains(domains []*Domain) []gateway.Domain { + out := make([]gateway.Domain, len(domains)) + for i, d := range domains { + out[i] = d + } + return out +} + +func validate(d *Domain) error { + seen := map[string]bool{gateway.DescribeAction: true} + sorted := sort.SliceIsSorted(d.Operations, func(i, j int) bool { + return d.Operations[i].Action < d.Operations[j].Action + }) + if !sorted { + return fmt.Errorf("operations must be sorted by action name") + } + for _, op := range d.Operations { + if seen[op.Action] { + return fmt.Errorf("duplicate or reserved action %q", op.Action) + } + seen[op.Action] = true + if op.ReadOnly != (op.Method == "GET") { + return fmt.Errorf("action %q: readonly must mirror the method (GET and only GET reads)", op.Action) + } + if err := validateParams(op); err != nil { + return fmt.Errorf("action %q: %w", op.Action, err) + } + } + return nil +} + +func validateParams(op *Operation) error { + pathParams := map[string]bool{} + for _, p := range op.Params { + switch p.In { + case "path": + if !p.Required { + return fmt.Errorf("path param %q must be required", p.Name) + } + pathParams[p.Name] = true + case "query": + default: + return fmt.Errorf("param %q: in must be \"path\" or \"query\", got %q", p.Name, p.In) + } + if p.Schema == nil { + return fmt.Errorf("param %q has no schema", p.Name) + } + } + for _, token := range PathTokens(op.Path) { + if !pathParams[token] { + return fmt.Errorf("path token {%s} has no matching path param", token) + } + delete(pathParams, token) + } + for name := range pathParams { + return fmt.Errorf("path param %q does not appear in path %q", name, op.Path) + } + if op.Body != nil && op.Body["type"] != "object" { + return fmt.Errorf("body schema must be an object schema") + } + if op.Body == nil && op.BodyKey != "" { + return fmt.Errorf("body_key %q without a body schema", op.BodyKey) + } + return nil +} + +// PathTokens returns the {name} tokens in a path template, in order. +func PathTokens(path string) []string { + var tokens []string + rest := path + for { + i := strings.IndexByte(rest, '{') + if i < 0 { + return tokens + } + rest = rest[i+1:] + j := strings.IndexByte(rest, '}') + if j < 0 { + return tokens + } + tokens = append(tokens, rest[:j]) + rest = rest[j+1:] + } +} diff --git a/internal/mcpserver/catalog/catalog_test.go b/internal/mcpserver/catalog/catalog_test.go new file mode 100644 index 0000000..221b873 --- /dev/null +++ b/internal/mcpserver/catalog/catalog_test.go @@ -0,0 +1,219 @@ +// These tests pin the duplicated catalog: the curation validates cleanly, +// the invariants the dispatcher relies on hold, and the full rendered +// surface is snapshotted so any change — including a sync from the sibling +// in fizzy-mcp-server — shows its effect as a reviewable diff. The generic +// gateway machinery is covered by the toolkit's own suite in +// github.com/basecamp/mcp/gateway. +package catalog + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/basecamp/mcp/mcptest" +) + +func load(t *testing.T) []*Domain { + t.Helper() + domains, err := Load() + if err != nil { + t.Fatalf("catalog must validate cleanly: %v", err) + } + return domains +} + +func TestCatalogShape(t *testing.T) { + domains := load(t) + + keys := make([]string, 0, len(domains)) + total := 0 + for _, d := range domains { + keys = append(keys, d.Key) + total += len(d.Operations) + if d.Tool != "fizzy_"+d.Key { + t.Errorf("domain %q tool = %q", d.Key, d.Tool) + } + if d.Blurb == "" { + t.Errorf("domain %q has no blurb", d.Key) + } + } + want := []string{"identity", "boards", "columns", "cards", "comments", "steps", "tags", "users"} + if got, expected := strings.Join(keys, ","), strings.Join(want, ","); got != expected { + t.Errorf("domains = %s, want %s", got, expected) + } + if total != 45 { + t.Errorf("operations = %d, want 45 (the count is deliberate; update alongside the snapshot)", total) + } +} + +func TestOnlyIdentityIsUnscoped(t *testing.T) { + for _, d := range load(t) { + for _, op := range d.Operations { + if op.Unscoped && op.Action != "get_identity" { + t.Errorf("%s/%s is unscoped; unscoped operations are the deliberate exception", d.Key, op.Action) + } + } + } +} + +func TestPaginatedOperationsTakePage(t *testing.T) { + for _, d := range load(t) { + for _, op := range d.Operations { + hasPage := false + for _, p := range op.Params { + if p.Name == "page" && p.In == "query" { + hasPage = true + } + } + if op.Paginated != hasPage { + t.Errorf("%s/%s: paginated and the page param travel together", d.Key, op.Action) + } + } + } +} + +func TestBodyRequiredFieldsExist(t *testing.T) { + for _, d := range load(t) { + for _, op := range d.Operations { + if op.Body == nil { + continue + } + props, ok := op.Body["properties"].(map[string]any) + if !ok { + t.Errorf("%s/%s: body must declare properties", d.Key, op.Action) + continue + } + if required, ok := op.Body["required"].([]any); ok { + for _, name := range required { + field, _ := name.(string) + if _, present := props[field]; !present { + t.Errorf("%s/%s: required field %q must be a property", d.Key, op.Action, field) + } + } + } + } + } +} + +func TestValidateRejectsCurationMistakes(t *testing.T) { + cases := []struct { + name string + domain *Domain + want string + }{ + { + "unsorted operations", + &Domain{Key: "x", Tool: "fizzy_x", Operations: []*Operation{ + {Action: "b", Method: "GET", Path: "/b", ReadOnly: true}, + {Action: "a", Method: "GET", Path: "/a", ReadOnly: true}, + }}, + "sorted", + }, + { + "reserved describe action", + &Domain{Key: "x", Tool: "fizzy_x", Operations: []*Operation{ + {Action: "describe", Method: "GET", Path: "/x", ReadOnly: true}, + }}, + "reserved", + }, + { + "write marked read-only", + &Domain{Key: "x", Tool: "fizzy_x", Operations: []*Operation{ + {Action: "a", Method: "POST", Path: "/a", ReadOnly: true}, + }}, + "readonly must mirror the method", + }, + { + "path token without param", + &Domain{Key: "x", Tool: "fizzy_x", Operations: []*Operation{ + {Action: "a", Method: "GET", Path: "/a/{id}", ReadOnly: true}, + }}, + "no matching path param", + }, + { + "path param not in path", + &Domain{Key: "x", Tool: "fizzy_x", Operations: []*Operation{ + {Action: "a", Method: "GET", Path: "/a", ReadOnly: true, + Params: []Param{{Name: "id", In: "path", Required: true, Schema: str("x")}}}, + }}, + "does not appear in path", + }, + { + "body_key without body", + &Domain{Key: "x", Tool: "fizzy_x", Operations: []*Operation{ + {Action: "a", Method: "POST", Path: "/a", BodyKey: "thing"}, + }}, + "without a body schema", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validate(tc.domain) + if err == nil { + t.Fatal("validate accepted the mistake") + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("error = %q, want it to mention %q", err, tc.want) + } + }) + } +} + +// TestProvenanceRecordsTheSibling keeps the duplication honest: the +// catalog is synced from fizzy-mcp-server by scripts/sync-mcp-catalog.sh, +// and the provenance names the source commit so drift is traceable. +func TestProvenanceRecordsTheSibling(t *testing.T) { + data, err := os.ReadFile("PROVENANCE.json") + if err != nil { + t.Fatalf("PROVENANCE.json: %v", err) + } + var provenance struct { + Source string `json:"source"` + Commit string `json:"commit"` + } + if err := json.Unmarshal(data, &provenance); err != nil { + t.Fatalf("PROVENANCE.json: %v", err) + } + if provenance.Source != "github.com/basecamp/fizzy-mcp-server" { + t.Errorf("provenance source = %q", provenance.Source) + } + if provenance.Commit == "" { + t.Error("provenance commit is empty") + } +} + +// TestSnapshot renders the entire served surface — tool names, +// descriptions, input schemas, and every action's describe payload — so a +// catalog change shows its full effect as a reviewable diff. Regenerate +// with `go test ./internal/mcpserver/catalog -update`. +func TestSnapshot(t *testing.T) { + var b strings.Builder + for _, d := range load(t) { + b.WriteString("==== TOOL " + d.ToolName() + " ====\n") + b.WriteString(d.Description()) + b.WriteString("---- input schema ----\n") + writeJSON(t, &b, d.InputSchema()) + for _, op := range d.Operations { + b.WriteString("---- describe " + op.Action + " ----\n") + payload, err := d.Describe(op.Action) + if err != nil { + t.Fatal(err) + } + writeJSON(t, &b, payload) + } + } + mcptest.Snapshot(t, filepath.Join("testdata", "catalog_snapshot.txt"), []byte(b.String())) +} + +func writeJSON(t *testing.T, b *strings.Builder, v any) { + t.Helper() + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + t.Fatal(err) + } + b.Write(data) + b.WriteString("\n") +} diff --git a/internal/mcpserver/catalog/domains.go b/internal/mcpserver/catalog/domains.go new file mode 100644 index 0000000..4a3a765 --- /dev/null +++ b/internal/mcpserver/catalog/domains.go @@ -0,0 +1,401 @@ +package catalog + +// Schema shorthands for the hand-written specs below. +func str(desc string) map[string]any { + return map[string]any{"type": "string", "description": desc} +} + +func strEnum(desc string, values ...any) map[string]any { + return map[string]any{"type": "string", "description": desc, "enum": values} +} + +func boolean(desc string) map[string]any { + return map[string]any{"type": "boolean", "description": desc} +} + +func integer(desc string) map[string]any { + return map[string]any{"type": "integer", "description": desc} +} + +func strArray(desc string) map[string]any { + return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": desc} +} + +func object(properties map[string]any, required ...any) map[string]any { + schema := map[string]any{"type": "object", "properties": properties} + if len(required) > 0 { + schema["required"] = required + } + return schema +} + +func pathParam(name, desc string) Param { + return Param{Name: name, In: "path", Required: true, Description: desc, Schema: str(desc)} +} + +func queryParam(name, desc string, schema map[string]any) Param { + return Param{Name: name, In: "query", Description: desc, Schema: schema} +} + +func pageParam() Param { + return queryParam("page", "Page number; responses include next_page when more results exist.", integer("Page number")) +} + +var cardNumber = pathParam("card_number", "The card number (the number in the card's URL, not its ID)") + +// Domains is the hand-written v1 catalog, in tool display order. Each +// operation mirrors one endpoint in Fizzy's public API docs (docs/api in +// the fizzy repo); operations within a domain are sorted by action name. +var Domains = []*Domain{ + { + Key: "identity", + Tool: "fizzy_identity", + Blurb: "Who you are on Fizzy: the accounts your token can reach and your user in each. Call this first when the account slug is unknown.", + Operations: []*Operation{ + { + Action: "get_identity", Method: "GET", Path: "/my/identity", ReadOnly: true, Unscoped: true, + Summary: "List the accounts the token can access, with your user record in each", + Doc: "Each account carries a slug; account-scoped actions use it automatically when FIZZY_ACCOUNT is set, or discover it here when exactly one account exists.", + }, + }, + }, + { + Key: "boards", + Tool: "fizzy_boards", + Blurb: "Fizzy boards: the kanban boards cards live on, who can access them, and public publication.", + Operations: []*Operation{ + { + Action: "create_board", Method: "POST", Path: "/boards", BodyKey: "board", + Summary: "Create a board", + Body: object(map[string]any{ + "name": str("The name of the board"), + "all_access": boolean("Whether any user in the account can access this board (default true)"), + "auto_postpone_period_in_days": integer("Days of inactivity before cards are automatically postponed"), + "public_description": str("Rich text description shown on the public board page"), + }, "name"), + }, + { + Action: "delete_board", Method: "DELETE", Path: "/boards/{board_id}", + Summary: "Delete a board (board administrators only)", + Params: []Param{pathParam("board_id", "The board ID")}, + }, + { + Action: "get_board", Method: "GET", Path: "/boards/{board_id}", ReadOnly: true, + Summary: "Get one board", + Params: []Param{pathParam("board_id", "The board ID")}, + }, + { + Action: "list_accesses", Method: "GET", Path: "/boards/{board_id}/accesses", ReadOnly: true, Paginated: true, + Summary: "List account users with their access and involvement for a board", + Params: []Param{pathParam("board_id", "The board ID"), pageParam()}, + }, + { + Action: "list_boards", Method: "GET", Path: "/boards", ReadOnly: true, + Summary: "List the boards you have access to", + }, + { + Action: "publish_board", Method: "POST", Path: "/boards/{board_id}/publication", + Summary: "Publish a board to a shareable public link (administrators only)", + Params: []Param{pathParam("board_id", "The board ID")}, + }, + { + Action: "unpublish_board", Method: "DELETE", Path: "/boards/{board_id}/publication", + Summary: "Unpublish a board, removing public access (administrators only)", + Params: []Param{pathParam("board_id", "The board ID")}, + }, + { + Action: "update_board", Method: "PUT", Path: "/boards/{board_id}", BodyKey: "board", + Summary: "Update a board (administrators only)", + Params: []Param{pathParam("board_id", "The board ID")}, + Body: object(map[string]any{ + "name": str("The name of the board"), + "all_access": boolean("Whether any user in the account can access this board"), + "auto_postpone_period_in_days": integer("Days of inactivity before cards are automatically postponed"), + "public_description": str("Rich text description shown on the public board page"), + "user_ids": strArray("All user IDs who should have access (only when all_access is false; replaces the whole list)"), + }), + }, + }, + }, + { + Key: "columns", + Tool: "fizzy_columns", + Blurb: "Workflow columns on a Fizzy board. Cards in Maybe?, Not Now, or Done live outside columns; move cards between columns with the cards tool's triage_card.", + Operations: []*Operation{ + { + Action: "create_column", Method: "POST", Path: "/boards/{board_id}/columns", BodyKey: "column", + Summary: "Create a column on a board", + Params: []Param{pathParam("board_id", "The board ID")}, + Body: object(map[string]any{ + "name": str("The name of the column"), + "color": str("Column color, e.g. var(--color-card-default) (Blue), var(--color-card-1) (Gray) through var(--color-card-8) (Pink)"), + }, "name"), + }, + { + Action: "delete_column", Method: "DELETE", Path: "/boards/{board_id}/columns/{column_id}", + Summary: "Delete a column", + Params: []Param{pathParam("board_id", "The board ID"), pathParam("column_id", "The column ID")}, + }, + { + Action: "get_column", Method: "GET", Path: "/boards/{board_id}/columns/{column_id}", ReadOnly: true, + Summary: "Get one column", + Params: []Param{pathParam("board_id", "The board ID"), pathParam("column_id", "The column ID")}, + }, + { + Action: "list_cards", Method: "GET", Path: "/boards/{board_id}/columns/{column_id}/cards", ReadOnly: true, Paginated: true, + Summary: "List the open cards in a column", + Params: []Param{pathParam("board_id", "The board ID"), pathParam("column_id", "The column ID"), pageParam()}, + }, + { + Action: "list_columns", Method: "GET", Path: "/boards/{board_id}/columns", ReadOnly: true, + Summary: "List a board's columns", + Params: []Param{pathParam("board_id", "The board ID")}, + }, + { + Action: "update_column", Method: "PUT", Path: "/boards/{board_id}/columns/{column_id}", BodyKey: "column", + Summary: "Rename or recolor a column", + Params: []Param{pathParam("board_id", "The board ID"), pathParam("column_id", "The column ID")}, + Body: object(map[string]any{ + "name": str("The name of the column"), + "color": str("The column color"), + }), + }, + }, + }, + { + Key: "cards", + Tool: "fizzy_cards", + Blurb: "Fizzy cards: create, find, and update cards; close and reopen; move between columns (triage), boards, and Not Now; tag, assign, watch, and mark golden.", + Operations: []*Operation{ + { + Action: "close_card", Method: "POST", Path: "/cards/{card_number}/closure", + Summary: "Close a card (move it to Done)", + Params: []Param{cardNumber}, + }, + { + Action: "create_card", Method: "POST", Path: "/boards/{board_id}/cards", BodyKey: "card", + Summary: "Create a card on a board (new cards start in Maybe? triage)", + Params: []Param{pathParam("board_id", "The board ID")}, + Body: object(map[string]any{ + "title": str("The title of the card"), + "description": str("Rich text description of the card"), + "status": strEnum("Initial status (default published)", "published", "drafted"), + "tag_ids": strArray("Tag IDs to apply to the card"), + }, "title"), + }, + { + Action: "delete_card", Method: "DELETE", Path: "/cards/{card_number}", + Summary: "Delete a card (creator or board administrators only)", + Params: []Param{cardNumber}, + }, + { + Action: "get_card", Method: "GET", Path: "/cards/{card_number}", ReadOnly: true, + Summary: "Get one card with its board, column, assignees, tags, and steps", + Params: []Param{cardNumber}, + }, + { + Action: "list_cards", Method: "GET", Path: "/cards", ReadOnly: true, Paginated: true, + Summary: "List cards you have access to, filtered by board, column, tag, assignee, state, or search terms", + Params: []Param{ + queryParam("assignee_ids", "Filter by assignee user ID(s)", strArray("Assignee user IDs")), + queryParam("assignment_status", "Filter by assignment status", strEnum("Assignment status", "unassigned")), + queryParam("board_ids", "Filter by board ID(s)", strArray("Board IDs")), + queryParam("card_ids", "Filter to specific card ID(s)", strArray("Card IDs")), + queryParam("closer_ids", "Filter by user ID(s) who closed the cards", strArray("Closer user IDs")), + queryParam("closure", "Filter by closure date", strEnum("Closure date range", "today", "yesterday", "thisweek", "lastweek", "thismonth", "lastmonth", "thisyear", "lastyear")), + queryParam("column_ids", "Filter by workflow column ID(s); repeated values are ORed", strArray("Column IDs")), + queryParam("creation", "Filter by creation date", strEnum("Creation date range", "today", "yesterday", "thisweek", "lastweek", "thismonth", "lastmonth", "thisyear", "lastyear")), + queryParam("creator_ids", "Filter by card creator ID(s)", strArray("Creator user IDs")), + queryParam("indexed_by", "Filter by card state", strEnum("Card state", "all", "maybe", "closed", "not_now", "stalled", "postponing_soon", "golden")), + pageParam(), + queryParam("sorted_by", "Sort order", strEnum("Sort order", "latest", "newest", "oldest")), + queryParam("tag_ids", "Filter by tag ID(s)", strArray("Tag IDs")), + queryParam("terms", "Search terms to filter cards", strArray("Search terms")), + }, + }, + { + Action: "mark_golden", Method: "POST", Path: "/cards/{card_number}/goldness", + Summary: "Mark a card as golden", + Params: []Param{cardNumber}, + }, + { + Action: "move_card", Method: "PUT", Path: "/cards/{card_number}/board", + Summary: "Move a card to a different board", + Params: []Param{cardNumber}, + Body: object(map[string]any{ + "board_id": str("The ID of the board to move the card to"), + }, "board_id"), + }, + { + Action: "postpone_card", Method: "POST", Path: "/cards/{card_number}/not_now", + Summary: "Move a card to Not Now", + Params: []Param{cardNumber}, + }, + { + Action: "reopen_card", Method: "DELETE", Path: "/cards/{card_number}/closure", + Summary: "Reopen a closed card", + Params: []Param{cardNumber}, + }, + { + Action: "toggle_assignment", Method: "POST", Path: "/cards/{card_number}/assignments", + Summary: "Toggle assignment of a user to/from a card", + Params: []Param{cardNumber}, + Body: object(map[string]any{ + "assignee_id": str("The ID of the user to assign or unassign"), + }, "assignee_id"), + }, + { + Action: "toggle_tag", Method: "POST", Path: "/cards/{card_number}/taggings", + Summary: "Toggle a tag on or off for a card, creating the tag if needed", + Params: []Param{cardNumber}, + Body: object(map[string]any{ + "tag_title": str("The title of the tag (leading # is stripped)"), + }, "tag_title"), + }, + { + Action: "triage_card", Method: "POST", Path: "/cards/{card_number}/triage", + Summary: "Move a card into a workflow column", + Params: []Param{cardNumber}, + Body: object(map[string]any{ + "column_id": str("The ID of the column to move the card into"), + }, "column_id"), + }, + { + Action: "unmark_golden", Method: "DELETE", Path: "/cards/{card_number}/goldness", + Summary: "Remove golden status from a card", + Params: []Param{cardNumber}, + }, + { + Action: "untriage_card", Method: "DELETE", Path: "/cards/{card_number}/triage", + Summary: "Send a card back to triage (Maybe?)", + Params: []Param{cardNumber}, + }, + { + Action: "unwatch_card", Method: "DELETE", Path: "/cards/{card_number}/watch", + Summary: "Unsubscribe from notifications for a card", + Params: []Param{cardNumber}, + }, + { + Action: "update_card", Method: "PUT", Path: "/cards/{card_number}", BodyKey: "card", + Summary: "Update a card's title, description, status, or tags", + Params: []Param{cardNumber}, + Body: object(map[string]any{ + "title": str("The title of the card"), + "description": str("Rich text description of the card"), + "status": strEnum("Card status", "drafted", "published"), + "tag_ids": strArray("Tag IDs to apply to the card"), + }), + }, + { + Action: "watch_card", Method: "POST", Path: "/cards/{card_number}/watch", + Summary: "Subscribe to notifications for a card", + Params: []Param{cardNumber}, + }, + }, + }, + { + Key: "comments", + Tool: "fizzy_comments", + Blurb: "Comments on Fizzy cards, chronological. Bodies support rich text.", + Operations: []*Operation{ + { + Action: "create_comment", Method: "POST", Path: "/cards/{card_number}/comments", BodyKey: "comment", + Summary: "Comment on a card", + Params: []Param{cardNumber}, + Body: object(map[string]any{ + "body": str("The comment body (supports rich text)"), + }, "body"), + }, + { + Action: "delete_comment", Method: "DELETE", Path: "/cards/{card_number}/comments/{comment_id}", + Summary: "Delete a comment (comment creator only)", + Params: []Param{cardNumber, pathParam("comment_id", "The comment ID")}, + }, + { + Action: "get_comment", Method: "GET", Path: "/cards/{card_number}/comments/{comment_id}", ReadOnly: true, + Summary: "Get one comment", + Params: []Param{cardNumber, pathParam("comment_id", "The comment ID")}, + }, + { + Action: "list_comments", Method: "GET", Path: "/cards/{card_number}/comments", ReadOnly: true, Paginated: true, + Summary: "List a card's comments, oldest first", + Params: []Param{cardNumber, pageParam()}, + }, + { + Action: "update_comment", Method: "PUT", Path: "/cards/{card_number}/comments/{comment_id}", BodyKey: "comment", + Summary: "Update a comment (comment creator only)", + Params: []Param{cardNumber, pathParam("comment_id", "The comment ID")}, + Body: object(map[string]any{ + "body": str("The updated comment body"), + }, "body"), + }, + }, + }, + { + Key: "steps", + Tool: "fizzy_steps", + Blurb: "Steps: the checklist items on a Fizzy card.", + Operations: []*Operation{ + { + Action: "create_step", Method: "POST", Path: "/cards/{card_number}/steps", BodyKey: "step", + Summary: "Add a step to a card", + Params: []Param{cardNumber}, + Body: object(map[string]any{ + "content": str("The step text"), + "completed": boolean("Whether the step is completed (default false)"), + }, "content"), + }, + { + Action: "delete_step", Method: "DELETE", Path: "/cards/{card_number}/steps/{step_id}", + Summary: "Delete a step", + Params: []Param{cardNumber, pathParam("step_id", "The step ID")}, + }, + { + Action: "get_step", Method: "GET", Path: "/cards/{card_number}/steps/{step_id}", ReadOnly: true, + Summary: "Get one step", + Params: []Param{cardNumber, pathParam("step_id", "The step ID")}, + }, + { + Action: "list_steps", Method: "GET", Path: "/cards/{card_number}/steps", ReadOnly: true, + Summary: "List a card's steps", + Params: []Param{cardNumber}, + }, + { + Action: "update_step", Method: "PUT", Path: "/cards/{card_number}/steps/{step_id}", BodyKey: "step", + Summary: "Edit a step or toggle its completion", + Params: []Param{cardNumber, pathParam("step_id", "The step ID")}, + Body: object(map[string]any{ + "content": str("The step text"), + "completed": boolean("Whether the step is completed"), + }), + }, + }, + }, + { + Key: "tags", + Tool: "fizzy_tags", + Blurb: "Tags: the labels applied to cards, account-wide. Apply or remove them with the cards tool's toggle_tag.", + Operations: []*Operation{ + { + Action: "list_tags", Method: "GET", Path: "/tags", ReadOnly: true, + Summary: "List the account's tags, alphabetically", + }, + }, + }, + { + Key: "users", + Tool: "fizzy_users", + Blurb: "Users: the people in the Fizzy account. Assign them to cards with the cards tool's toggle_assignment.", + Operations: []*Operation{ + { + Action: "get_user", Method: "GET", Path: "/users/{user_id}", ReadOnly: true, + Summary: "Get one user", + Params: []Param{pathParam("user_id", "The user ID")}, + }, + { + Action: "list_users", Method: "GET", Path: "/users", ReadOnly: true, + Summary: "List the account's active users", + }, + }, + }, +} diff --git a/internal/mcpserver/catalog/testdata/catalog_snapshot.txt b/internal/mcpserver/catalog/testdata/catalog_snapshot.txt new file mode 100644 index 0000000..879dda3 --- /dev/null +++ b/internal/mcpserver/catalog/testdata/catalog_snapshot.txt @@ -0,0 +1,1707 @@ +==== TOOL fizzy_identity ==== +Who you are on Fizzy: the accounts your token can reach and your user in each. Call this first when the account slug is unknown. + +Gateway tool: call with {"action": "...", "params": {...}}. +Call {"action": "describe", "params": {"action": "NAME"}} for an action's full parameter schema. + +ACTIONS (RO = read-only): +- get_identity (RO): List the accounts the token can access, with your user record in each +---- input schema ---- +{ + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "get_identity", + "describe" + ], + "type": "string" + }, + "params": { + "description": "Parameters for the action. Call describe for the action's schema.", + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object" +} +---- describe get_identity ---- +{ + "action": "get_identity", + "method": "GET", + "path": "/my/identity", + "summary": "List the accounts the token can access, with your user record in each", + "doc": "Each account carries a slug; account-scoped actions use it automatically when FIZZY_ACCOUNT is set, or discover it here when exactly one account exists.", + "readonly": true, + "unscoped": true +} +==== TOOL fizzy_boards ==== +Fizzy boards: the kanban boards cards live on, who can access them, and public publication. + +Gateway tool: call with {"action": "...", "params": {...}}. +Call {"action": "describe", "params": {"action": "NAME"}} for an action's full parameter schema. + +ACTIONS (RO = read-only): +- create_board: Create a board +- delete_board: Delete a board (board administrators only) +- get_board (RO): Get one board +- list_accesses (RO, paginated): List account users with their access and involvement for a board +- list_boards (RO): List the boards you have access to +- publish_board: Publish a board to a shareable public link (administrators only) +- unpublish_board: Unpublish a board, removing public access (administrators only) +- update_board: Update a board (administrators only) +---- input schema ---- +{ + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "create_board", + "delete_board", + "get_board", + "list_accesses", + "list_boards", + "publish_board", + "unpublish_board", + "update_board", + "describe" + ], + "type": "string" + }, + "params": { + "description": "Parameters for the action. Call describe for the action's schema.", + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object" +} +---- describe create_board ---- +{ + "action": "create_board", + "method": "POST", + "path": "/boards", + "summary": "Create a board", + "readonly": false, + "body": { + "properties": { + "all_access": { + "description": "Whether any user in the account can access this board (default true)", + "type": "boolean" + }, + "auto_postpone_period_in_days": { + "description": "Days of inactivity before cards are automatically postponed", + "type": "integer" + }, + "name": { + "description": "The name of the board", + "type": "string" + }, + "public_description": { + "description": "Rich text description shown on the public board page", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "body_key": "board" +} +---- describe delete_board ---- +{ + "action": "delete_board", + "method": "DELETE", + "path": "/boards/{board_id}", + "summary": "Delete a board (board administrators only)", + "readonly": false, + "params": [ + { + "name": "board_id", + "in": "path", + "required": true, + "description": "The board ID", + "schema": { + "description": "The board ID", + "type": "string" + } + } + ] +} +---- describe get_board ---- +{ + "action": "get_board", + "method": "GET", + "path": "/boards/{board_id}", + "summary": "Get one board", + "readonly": true, + "params": [ + { + "name": "board_id", + "in": "path", + "required": true, + "description": "The board ID", + "schema": { + "description": "The board ID", + "type": "string" + } + } + ] +} +---- describe list_accesses ---- +{ + "action": "list_accesses", + "method": "GET", + "path": "/boards/{board_id}/accesses", + "summary": "List account users with their access and involvement for a board", + "readonly": true, + "paginated": true, + "params": [ + { + "name": "board_id", + "in": "path", + "required": true, + "description": "The board ID", + "schema": { + "description": "The board ID", + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "Page number; responses include next_page when more results exist.", + "schema": { + "description": "Page number", + "type": "integer" + } + } + ] +} +---- describe list_boards ---- +{ + "action": "list_boards", + "method": "GET", + "path": "/boards", + "summary": "List the boards you have access to", + "readonly": true +} +---- describe publish_board ---- +{ + "action": "publish_board", + "method": "POST", + "path": "/boards/{board_id}/publication", + "summary": "Publish a board to a shareable public link (administrators only)", + "readonly": false, + "params": [ + { + "name": "board_id", + "in": "path", + "required": true, + "description": "The board ID", + "schema": { + "description": "The board ID", + "type": "string" + } + } + ] +} +---- describe unpublish_board ---- +{ + "action": "unpublish_board", + "method": "DELETE", + "path": "/boards/{board_id}/publication", + "summary": "Unpublish a board, removing public access (administrators only)", + "readonly": false, + "params": [ + { + "name": "board_id", + "in": "path", + "required": true, + "description": "The board ID", + "schema": { + "description": "The board ID", + "type": "string" + } + } + ] +} +---- describe update_board ---- +{ + "action": "update_board", + "method": "PUT", + "path": "/boards/{board_id}", + "summary": "Update a board (administrators only)", + "readonly": false, + "params": [ + { + "name": "board_id", + "in": "path", + "required": true, + "description": "The board ID", + "schema": { + "description": "The board ID", + "type": "string" + } + } + ], + "body": { + "properties": { + "all_access": { + "description": "Whether any user in the account can access this board", + "type": "boolean" + }, + "auto_postpone_period_in_days": { + "description": "Days of inactivity before cards are automatically postponed", + "type": "integer" + }, + "name": { + "description": "The name of the board", + "type": "string" + }, + "public_description": { + "description": "Rich text description shown on the public board page", + "type": "string" + }, + "user_ids": { + "description": "All user IDs who should have access (only when all_access is false; replaces the whole list)", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "body_key": "board" +} +==== TOOL fizzy_columns ==== +Workflow columns on a Fizzy board. Cards in Maybe?, Not Now, or Done live outside columns; move cards between columns with the cards tool's triage_card. + +Gateway tool: call with {"action": "...", "params": {...}}. +Call {"action": "describe", "params": {"action": "NAME"}} for an action's full parameter schema. + +ACTIONS (RO = read-only): +- create_column: Create a column on a board +- delete_column: Delete a column +- get_column (RO): Get one column +- list_cards (RO, paginated): List the open cards in a column +- list_columns (RO): List a board's columns +- update_column: Rename or recolor a column +---- input schema ---- +{ + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "create_column", + "delete_column", + "get_column", + "list_cards", + "list_columns", + "update_column", + "describe" + ], + "type": "string" + }, + "params": { + "description": "Parameters for the action. Call describe for the action's schema.", + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object" +} +---- describe create_column ---- +{ + "action": "create_column", + "method": "POST", + "path": "/boards/{board_id}/columns", + "summary": "Create a column on a board", + "readonly": false, + "params": [ + { + "name": "board_id", + "in": "path", + "required": true, + "description": "The board ID", + "schema": { + "description": "The board ID", + "type": "string" + } + } + ], + "body": { + "properties": { + "color": { + "description": "Column color, e.g. var(--color-card-default) (Blue), var(--color-card-1) (Gray) through var(--color-card-8) (Pink)", + "type": "string" + }, + "name": { + "description": "The name of the column", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "body_key": "column" +} +---- describe delete_column ---- +{ + "action": "delete_column", + "method": "DELETE", + "path": "/boards/{board_id}/columns/{column_id}", + "summary": "Delete a column", + "readonly": false, + "params": [ + { + "name": "board_id", + "in": "path", + "required": true, + "description": "The board ID", + "schema": { + "description": "The board ID", + "type": "string" + } + }, + { + "name": "column_id", + "in": "path", + "required": true, + "description": "The column ID", + "schema": { + "description": "The column ID", + "type": "string" + } + } + ] +} +---- describe get_column ---- +{ + "action": "get_column", + "method": "GET", + "path": "/boards/{board_id}/columns/{column_id}", + "summary": "Get one column", + "readonly": true, + "params": [ + { + "name": "board_id", + "in": "path", + "required": true, + "description": "The board ID", + "schema": { + "description": "The board ID", + "type": "string" + } + }, + { + "name": "column_id", + "in": "path", + "required": true, + "description": "The column ID", + "schema": { + "description": "The column ID", + "type": "string" + } + } + ] +} +---- describe list_cards ---- +{ + "action": "list_cards", + "method": "GET", + "path": "/boards/{board_id}/columns/{column_id}/cards", + "summary": "List the open cards in a column", + "readonly": true, + "paginated": true, + "params": [ + { + "name": "board_id", + "in": "path", + "required": true, + "description": "The board ID", + "schema": { + "description": "The board ID", + "type": "string" + } + }, + { + "name": "column_id", + "in": "path", + "required": true, + "description": "The column ID", + "schema": { + "description": "The column ID", + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "Page number; responses include next_page when more results exist.", + "schema": { + "description": "Page number", + "type": "integer" + } + } + ] +} +---- describe list_columns ---- +{ + "action": "list_columns", + "method": "GET", + "path": "/boards/{board_id}/columns", + "summary": "List a board's columns", + "readonly": true, + "params": [ + { + "name": "board_id", + "in": "path", + "required": true, + "description": "The board ID", + "schema": { + "description": "The board ID", + "type": "string" + } + } + ] +} +---- describe update_column ---- +{ + "action": "update_column", + "method": "PUT", + "path": "/boards/{board_id}/columns/{column_id}", + "summary": "Rename or recolor a column", + "readonly": false, + "params": [ + { + "name": "board_id", + "in": "path", + "required": true, + "description": "The board ID", + "schema": { + "description": "The board ID", + "type": "string" + } + }, + { + "name": "column_id", + "in": "path", + "required": true, + "description": "The column ID", + "schema": { + "description": "The column ID", + "type": "string" + } + } + ], + "body": { + "properties": { + "color": { + "description": "The column color", + "type": "string" + }, + "name": { + "description": "The name of the column", + "type": "string" + } + }, + "type": "object" + }, + "body_key": "column" +} +==== TOOL fizzy_cards ==== +Fizzy cards: create, find, and update cards; close and reopen; move between columns (triage), boards, and Not Now; tag, assign, watch, and mark golden. + +Gateway tool: call with {"action": "...", "params": {...}}. +Call {"action": "describe", "params": {"action": "NAME"}} for an action's full parameter schema. + +ACTIONS (RO = read-only): +- close_card: Close a card (move it to Done) +- create_card: Create a card on a board (new cards start in Maybe? triage) +- delete_card: Delete a card (creator or board administrators only) +- get_card (RO): Get one card with its board, column, assignees, tags, and steps +- list_cards (RO, paginated): List cards you have access to, filtered by board, column, tag, assignee, state, or search terms +- mark_golden: Mark a card as golden +- move_card: Move a card to a different board +- postpone_card: Move a card to Not Now +- reopen_card: Reopen a closed card +- toggle_assignment: Toggle assignment of a user to/from a card +- toggle_tag: Toggle a tag on or off for a card, creating the tag if needed +- triage_card: Move a card into a workflow column +- unmark_golden: Remove golden status from a card +- untriage_card: Send a card back to triage (Maybe?) +- unwatch_card: Unsubscribe from notifications for a card +- update_card: Update a card's title, description, status, or tags +- watch_card: Subscribe to notifications for a card +---- input schema ---- +{ + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "close_card", + "create_card", + "delete_card", + "get_card", + "list_cards", + "mark_golden", + "move_card", + "postpone_card", + "reopen_card", + "toggle_assignment", + "toggle_tag", + "triage_card", + "unmark_golden", + "untriage_card", + "unwatch_card", + "update_card", + "watch_card", + "describe" + ], + "type": "string" + }, + "params": { + "description": "Parameters for the action. Call describe for the action's schema.", + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object" +} +---- describe close_card ---- +{ + "action": "close_card", + "method": "POST", + "path": "/cards/{card_number}/closure", + "summary": "Close a card (move it to Done)", + "readonly": false, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + } + ] +} +---- describe create_card ---- +{ + "action": "create_card", + "method": "POST", + "path": "/boards/{board_id}/cards", + "summary": "Create a card on a board (new cards start in Maybe? triage)", + "readonly": false, + "params": [ + { + "name": "board_id", + "in": "path", + "required": true, + "description": "The board ID", + "schema": { + "description": "The board ID", + "type": "string" + } + } + ], + "body": { + "properties": { + "description": { + "description": "Rich text description of the card", + "type": "string" + }, + "status": { + "description": "Initial status (default published)", + "enum": [ + "published", + "drafted" + ], + "type": "string" + }, + "tag_ids": { + "description": "Tag IDs to apply to the card", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "description": "The title of the card", + "type": "string" + } + }, + "required": [ + "title" + ], + "type": "object" + }, + "body_key": "card" +} +---- describe delete_card ---- +{ + "action": "delete_card", + "method": "DELETE", + "path": "/cards/{card_number}", + "summary": "Delete a card (creator or board administrators only)", + "readonly": false, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + } + ] +} +---- describe get_card ---- +{ + "action": "get_card", + "method": "GET", + "path": "/cards/{card_number}", + "summary": "Get one card with its board, column, assignees, tags, and steps", + "readonly": true, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + } + ] +} +---- describe list_cards ---- +{ + "action": "list_cards", + "method": "GET", + "path": "/cards", + "summary": "List cards you have access to, filtered by board, column, tag, assignee, state, or search terms", + "readonly": true, + "paginated": true, + "params": [ + { + "name": "assignee_ids", + "in": "query", + "description": "Filter by assignee user ID(s)", + "schema": { + "description": "Assignee user IDs", + "items": { + "type": "string" + }, + "type": "array" + } + }, + { + "name": "assignment_status", + "in": "query", + "description": "Filter by assignment status", + "schema": { + "description": "Assignment status", + "enum": [ + "unassigned" + ], + "type": "string" + } + }, + { + "name": "board_ids", + "in": "query", + "description": "Filter by board ID(s)", + "schema": { + "description": "Board IDs", + "items": { + "type": "string" + }, + "type": "array" + } + }, + { + "name": "card_ids", + "in": "query", + "description": "Filter to specific card ID(s)", + "schema": { + "description": "Card IDs", + "items": { + "type": "string" + }, + "type": "array" + } + }, + { + "name": "closer_ids", + "in": "query", + "description": "Filter by user ID(s) who closed the cards", + "schema": { + "description": "Closer user IDs", + "items": { + "type": "string" + }, + "type": "array" + } + }, + { + "name": "closure", + "in": "query", + "description": "Filter by closure date", + "schema": { + "description": "Closure date range", + "enum": [ + "today", + "yesterday", + "thisweek", + "lastweek", + "thismonth", + "lastmonth", + "thisyear", + "lastyear" + ], + "type": "string" + } + }, + { + "name": "column_ids", + "in": "query", + "description": "Filter by workflow column ID(s); repeated values are ORed", + "schema": { + "description": "Column IDs", + "items": { + "type": "string" + }, + "type": "array" + } + }, + { + "name": "creation", + "in": "query", + "description": "Filter by creation date", + "schema": { + "description": "Creation date range", + "enum": [ + "today", + "yesterday", + "thisweek", + "lastweek", + "thismonth", + "lastmonth", + "thisyear", + "lastyear" + ], + "type": "string" + } + }, + { + "name": "creator_ids", + "in": "query", + "description": "Filter by card creator ID(s)", + "schema": { + "description": "Creator user IDs", + "items": { + "type": "string" + }, + "type": "array" + } + }, + { + "name": "indexed_by", + "in": "query", + "description": "Filter by card state", + "schema": { + "description": "Card state", + "enum": [ + "all", + "maybe", + "closed", + "not_now", + "stalled", + "postponing_soon", + "golden" + ], + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "Page number; responses include next_page when more results exist.", + "schema": { + "description": "Page number", + "type": "integer" + } + }, + { + "name": "sorted_by", + "in": "query", + "description": "Sort order", + "schema": { + "description": "Sort order", + "enum": [ + "latest", + "newest", + "oldest" + ], + "type": "string" + } + }, + { + "name": "tag_ids", + "in": "query", + "description": "Filter by tag ID(s)", + "schema": { + "description": "Tag IDs", + "items": { + "type": "string" + }, + "type": "array" + } + }, + { + "name": "terms", + "in": "query", + "description": "Search terms to filter cards", + "schema": { + "description": "Search terms", + "items": { + "type": "string" + }, + "type": "array" + } + } + ] +} +---- describe mark_golden ---- +{ + "action": "mark_golden", + "method": "POST", + "path": "/cards/{card_number}/goldness", + "summary": "Mark a card as golden", + "readonly": false, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + } + ] +} +---- describe move_card ---- +{ + "action": "move_card", + "method": "PUT", + "path": "/cards/{card_number}/board", + "summary": "Move a card to a different board", + "readonly": false, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + } + ], + "body": { + "properties": { + "board_id": { + "description": "The ID of the board to move the card to", + "type": "string" + } + }, + "required": [ + "board_id" + ], + "type": "object" + } +} +---- describe postpone_card ---- +{ + "action": "postpone_card", + "method": "POST", + "path": "/cards/{card_number}/not_now", + "summary": "Move a card to Not Now", + "readonly": false, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + } + ] +} +---- describe reopen_card ---- +{ + "action": "reopen_card", + "method": "DELETE", + "path": "/cards/{card_number}/closure", + "summary": "Reopen a closed card", + "readonly": false, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + } + ] +} +---- describe toggle_assignment ---- +{ + "action": "toggle_assignment", + "method": "POST", + "path": "/cards/{card_number}/assignments", + "summary": "Toggle assignment of a user to/from a card", + "readonly": false, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + } + ], + "body": { + "properties": { + "assignee_id": { + "description": "The ID of the user to assign or unassign", + "type": "string" + } + }, + "required": [ + "assignee_id" + ], + "type": "object" + } +} +---- describe toggle_tag ---- +{ + "action": "toggle_tag", + "method": "POST", + "path": "/cards/{card_number}/taggings", + "summary": "Toggle a tag on or off for a card, creating the tag if needed", + "readonly": false, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + } + ], + "body": { + "properties": { + "tag_title": { + "description": "The title of the tag (leading # is stripped)", + "type": "string" + } + }, + "required": [ + "tag_title" + ], + "type": "object" + } +} +---- describe triage_card ---- +{ + "action": "triage_card", + "method": "POST", + "path": "/cards/{card_number}/triage", + "summary": "Move a card into a workflow column", + "readonly": false, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + } + ], + "body": { + "properties": { + "column_id": { + "description": "The ID of the column to move the card into", + "type": "string" + } + }, + "required": [ + "column_id" + ], + "type": "object" + } +} +---- describe unmark_golden ---- +{ + "action": "unmark_golden", + "method": "DELETE", + "path": "/cards/{card_number}/goldness", + "summary": "Remove golden status from a card", + "readonly": false, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + } + ] +} +---- describe untriage_card ---- +{ + "action": "untriage_card", + "method": "DELETE", + "path": "/cards/{card_number}/triage", + "summary": "Send a card back to triage (Maybe?)", + "readonly": false, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + } + ] +} +---- describe unwatch_card ---- +{ + "action": "unwatch_card", + "method": "DELETE", + "path": "/cards/{card_number}/watch", + "summary": "Unsubscribe from notifications for a card", + "readonly": false, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + } + ] +} +---- describe update_card ---- +{ + "action": "update_card", + "method": "PUT", + "path": "/cards/{card_number}", + "summary": "Update a card's title, description, status, or tags", + "readonly": false, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + } + ], + "body": { + "properties": { + "description": { + "description": "Rich text description of the card", + "type": "string" + }, + "status": { + "description": "Card status", + "enum": [ + "drafted", + "published" + ], + "type": "string" + }, + "tag_ids": { + "description": "Tag IDs to apply to the card", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "description": "The title of the card", + "type": "string" + } + }, + "type": "object" + }, + "body_key": "card" +} +---- describe watch_card ---- +{ + "action": "watch_card", + "method": "POST", + "path": "/cards/{card_number}/watch", + "summary": "Subscribe to notifications for a card", + "readonly": false, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + } + ] +} +==== TOOL fizzy_comments ==== +Comments on Fizzy cards, chronological. Bodies support rich text. + +Gateway tool: call with {"action": "...", "params": {...}}. +Call {"action": "describe", "params": {"action": "NAME"}} for an action's full parameter schema. + +ACTIONS (RO = read-only): +- create_comment: Comment on a card +- delete_comment: Delete a comment (comment creator only) +- get_comment (RO): Get one comment +- list_comments (RO, paginated): List a card's comments, oldest first +- update_comment: Update a comment (comment creator only) +---- input schema ---- +{ + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "create_comment", + "delete_comment", + "get_comment", + "list_comments", + "update_comment", + "describe" + ], + "type": "string" + }, + "params": { + "description": "Parameters for the action. Call describe for the action's schema.", + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object" +} +---- describe create_comment ---- +{ + "action": "create_comment", + "method": "POST", + "path": "/cards/{card_number}/comments", + "summary": "Comment on a card", + "readonly": false, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + } + ], + "body": { + "properties": { + "body": { + "description": "The comment body (supports rich text)", + "type": "string" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "body_key": "comment" +} +---- describe delete_comment ---- +{ + "action": "delete_comment", + "method": "DELETE", + "path": "/cards/{card_number}/comments/{comment_id}", + "summary": "Delete a comment (comment creator only)", + "readonly": false, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + }, + { + "name": "comment_id", + "in": "path", + "required": true, + "description": "The comment ID", + "schema": { + "description": "The comment ID", + "type": "string" + } + } + ] +} +---- describe get_comment ---- +{ + "action": "get_comment", + "method": "GET", + "path": "/cards/{card_number}/comments/{comment_id}", + "summary": "Get one comment", + "readonly": true, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + }, + { + "name": "comment_id", + "in": "path", + "required": true, + "description": "The comment ID", + "schema": { + "description": "The comment ID", + "type": "string" + } + } + ] +} +---- describe list_comments ---- +{ + "action": "list_comments", + "method": "GET", + "path": "/cards/{card_number}/comments", + "summary": "List a card's comments, oldest first", + "readonly": true, + "paginated": true, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "Page number; responses include next_page when more results exist.", + "schema": { + "description": "Page number", + "type": "integer" + } + } + ] +} +---- describe update_comment ---- +{ + "action": "update_comment", + "method": "PUT", + "path": "/cards/{card_number}/comments/{comment_id}", + "summary": "Update a comment (comment creator only)", + "readonly": false, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + }, + { + "name": "comment_id", + "in": "path", + "required": true, + "description": "The comment ID", + "schema": { + "description": "The comment ID", + "type": "string" + } + } + ], + "body": { + "properties": { + "body": { + "description": "The updated comment body", + "type": "string" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "body_key": "comment" +} +==== TOOL fizzy_steps ==== +Steps: the checklist items on a Fizzy card. + +Gateway tool: call with {"action": "...", "params": {...}}. +Call {"action": "describe", "params": {"action": "NAME"}} for an action's full parameter schema. + +ACTIONS (RO = read-only): +- create_step: Add a step to a card +- delete_step: Delete a step +- get_step (RO): Get one step +- list_steps (RO): List a card's steps +- update_step: Edit a step or toggle its completion +---- input schema ---- +{ + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "create_step", + "delete_step", + "get_step", + "list_steps", + "update_step", + "describe" + ], + "type": "string" + }, + "params": { + "description": "Parameters for the action. Call describe for the action's schema.", + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object" +} +---- describe create_step ---- +{ + "action": "create_step", + "method": "POST", + "path": "/cards/{card_number}/steps", + "summary": "Add a step to a card", + "readonly": false, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + } + ], + "body": { + "properties": { + "completed": { + "description": "Whether the step is completed (default false)", + "type": "boolean" + }, + "content": { + "description": "The step text", + "type": "string" + } + }, + "required": [ + "content" + ], + "type": "object" + }, + "body_key": "step" +} +---- describe delete_step ---- +{ + "action": "delete_step", + "method": "DELETE", + "path": "/cards/{card_number}/steps/{step_id}", + "summary": "Delete a step", + "readonly": false, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + }, + { + "name": "step_id", + "in": "path", + "required": true, + "description": "The step ID", + "schema": { + "description": "The step ID", + "type": "string" + } + } + ] +} +---- describe get_step ---- +{ + "action": "get_step", + "method": "GET", + "path": "/cards/{card_number}/steps/{step_id}", + "summary": "Get one step", + "readonly": true, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + }, + { + "name": "step_id", + "in": "path", + "required": true, + "description": "The step ID", + "schema": { + "description": "The step ID", + "type": "string" + } + } + ] +} +---- describe list_steps ---- +{ + "action": "list_steps", + "method": "GET", + "path": "/cards/{card_number}/steps", + "summary": "List a card's steps", + "readonly": true, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + } + ] +} +---- describe update_step ---- +{ + "action": "update_step", + "method": "PUT", + "path": "/cards/{card_number}/steps/{step_id}", + "summary": "Edit a step or toggle its completion", + "readonly": false, + "params": [ + { + "name": "card_number", + "in": "path", + "required": true, + "description": "The card number (the number in the card's URL, not its ID)", + "schema": { + "description": "The card number (the number in the card's URL, not its ID)", + "type": "string" + } + }, + { + "name": "step_id", + "in": "path", + "required": true, + "description": "The step ID", + "schema": { + "description": "The step ID", + "type": "string" + } + } + ], + "body": { + "properties": { + "completed": { + "description": "Whether the step is completed", + "type": "boolean" + }, + "content": { + "description": "The step text", + "type": "string" + } + }, + "type": "object" + }, + "body_key": "step" +} +==== TOOL fizzy_tags ==== +Tags: the labels applied to cards, account-wide. Apply or remove them with the cards tool's toggle_tag. + +Gateway tool: call with {"action": "...", "params": {...}}. +Call {"action": "describe", "params": {"action": "NAME"}} for an action's full parameter schema. + +ACTIONS (RO = read-only): +- list_tags (RO): List the account's tags, alphabetically +---- input schema ---- +{ + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "list_tags", + "describe" + ], + "type": "string" + }, + "params": { + "description": "Parameters for the action. Call describe for the action's schema.", + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object" +} +---- describe list_tags ---- +{ + "action": "list_tags", + "method": "GET", + "path": "/tags", + "summary": "List the account's tags, alphabetically", + "readonly": true +} +==== TOOL fizzy_users ==== +Users: the people in the Fizzy account. Assign them to cards with the cards tool's toggle_assignment. + +Gateway tool: call with {"action": "...", "params": {...}}. +Call {"action": "describe", "params": {"action": "NAME"}} for an action's full parameter schema. + +ACTIONS (RO = read-only): +- get_user (RO): Get one user +- list_users (RO): List the account's active users +---- input schema ---- +{ + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "get_user", + "list_users", + "describe" + ], + "type": "string" + }, + "params": { + "description": "Parameters for the action. Call describe for the action's schema.", + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object" +} +---- describe get_user ---- +{ + "action": "get_user", + "method": "GET", + "path": "/users/{user_id}", + "summary": "Get one user", + "readonly": true, + "params": [ + { + "name": "user_id", + "in": "path", + "required": true, + "description": "The user ID", + "schema": { + "description": "The user ID", + "type": "string" + } + } + ] +} +---- describe list_users ---- +{ + "action": "list_users", + "method": "GET", + "path": "/users", + "summary": "List the account's active users", + "readonly": true +} diff --git a/internal/mcpserver/dispatch.go b/internal/mcpserver/dispatch.go new file mode 100644 index 0000000..c81b7b8 --- /dev/null +++ b/internal/mcpserver/dispatch.go @@ -0,0 +1,349 @@ +package mcpserver + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + + fizzy "github.com/basecamp/fizzy-sdk/go/pkg/fizzy" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/basecamp/mcp/gateway" + + "github.com/basecamp/fizzy-cli/internal/mcpserver/catalog" +) + +// API is the slice of the fizzy-sdk client the dispatcher drives. Both +// *fizzy.Client and *fizzy.AccountClient satisfy it; the SDK carries auth, +// retry, and base URL resolution — the AccountClient adds account-slug +// scoping — so the dispatcher only assembles paths and bodies. +type API interface { + Get(ctx context.Context, path string) (*fizzy.Response, error) + Post(ctx context.Context, path string, body any) (*fizzy.Response, error) + Put(ctx context.Context, path string, body any) (*fizzy.Response, error) + Patch(ctx context.Context, path string, body any) (*fizzy.Response, error) + Delete(ctx context.Context, path string) (*fizzy.Response, error) +} + +// The CLI hands its SDK clients straight to New. +var ( + _ API = (*fizzy.Client)(nil) + _ API = (*fizzy.AccountClient)(nil) +) + +// dispatcher turns catalog operations into fizzy-sdk requests. +// +// Calling convention (matching fizzy-mcp-server): the tool call's params +// object carries the operation's path and query parameters by name, and +// every remaining entry becomes a request body property, wrapped under the +// operation's body key. The describe action serves the schema for all +// three. Failures are in-band isError results per MCP convention. +type dispatcher struct { + account API // account-scoped operations (the catalog default) + root API // the few Unscoped operations, e.g. get_identity +} + +// handle dispatches one gateway action as a Fizzy API call. The gateway +// hands over only the dispatch surface (action, read-only); the full +// operation — method, path, param routing — is looked up on the concrete +// catalog domain. +func (d dispatcher) handle(ctx context.Context, dom gateway.Domain, op gateway.Operation, params map[string]any) (*mcp.CallToolResult, error) { + domain, ok := dom.(*catalog.Domain) + if !ok { + return gateway.ErrorResult("internal error: domain %q is not a catalog domain", dom.Name()), nil + } + full, ok := domain.Operation(op.Action) + if !ok { + return gateway.ErrorResult("internal error: action %q not in domain %q", op.Action, dom.Name()), nil + } + + path, body, err := buildRequest(full, params) + if err != nil { + return gateway.ErrorResult("%v", err), nil + } + + api := d.account + if full.Unscoped { + api = d.root + } + + res, err := call(ctx, api, full.Method, path, body) + if err != nil { + return errorResult(full.Method, path, err), nil + } + + return d.render(ctx, res) +} + +// errorResult renders a failed API call in-band. SDK errors carry the HTTP +// status; surface it so callers can tell a 404 from a 422. +func errorResult(method, path string, err error) *mcp.CallToolResult { + var apiErr *fizzy.Error + if errors.As(err, &apiErr) && apiErr.HTTPStatus != 0 { + return gateway.ErrorResult("fizzy returned %d %s: %v", apiErr.HTTPStatus, http.StatusText(apiErr.HTTPStatus), err) + } + return gateway.ErrorResult("%s %s failed: %v", method, path, err) +} + +// render translates a Fizzy response into an MCP result: JSON passes +// through, a bodiless success reports its status, a 201 Location is +// followed so create actions return the created resource, and a Link +// rel="next" page number rides along as next_page. +func (d dispatcher) render(ctx context.Context, res *fizzy.Response) (*mcp.CallToolResult, error) { + if bodiless(res.Data) && res.StatusCode == http.StatusCreated { + if location := res.Headers.Get("Location"); location != "" { + created, err := d.followLocation(ctx, location) + if err != nil { + // The write succeeded; report it rather than masking it as + // a failure because the follow-up read did not. + return gateway.JSONResult(map[string]any{ + "ok": true, "status": res.StatusCode, "location": location, + "note": fmt.Sprintf("created, but fetching %s failed: %v", location, err), + }) + } + res = created + } + } + + if bodiless(res.Data) { + return gateway.JSONResult(map[string]any{"ok": true, "status": res.StatusCode}) + } + + if next := nextPage(res.Headers.Get("Link")); next > 0 { + wrapped, err := json.Marshal(struct { + Data json.RawMessage `json:"data"` + NextPage int `json:"next_page"` + }{res.Data, next}) + if err == nil { + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(wrapped)}}}, nil + } + } + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(res.Data)}}}, nil +} + +// followLocation fetches a 201 Location so create actions answer with the +// created resource. Locations arrive account-prefixed (relative or +// absolute); an absolute URL is reduced to its path and query, keeping the +// request on the configured instance, and the root client is used so no +// second account prefix is added. +func (d dispatcher) followLocation(ctx context.Context, location string) (*fizzy.Response, error) { + if strings.HasPrefix(location, "http://") || strings.HasPrefix(location, "https://") { + u, err := url.Parse(location) + if err != nil { + return nil, fmt.Errorf("invalid location %q: %w", location, err) + } + location = u.RequestURI() + } + return d.root.Get(ctx, location) +} + +// bodiless reports whether a response carries no JSON payload. The SDK +// normalizes 204s to a literal null body. +func bodiless(data json.RawMessage) bool { + trimmed := bytes.TrimSpace(data) + return len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) +} + +// call maps the operation's method onto the SDK verbs. The SDK's GET and +// DELETE take no body, so a catalog operation declaring one is refused +// loudly rather than silently dropped — today no such operation exists, +// and this guard keeps a future catalog sync from introducing one +// unnoticed. +func call(ctx context.Context, api API, method, path string, body any) (*fizzy.Response, error) { + if body != nil && (method == http.MethodGet || method == http.MethodDelete) { + return nil, fmt.Errorf("internal error: %s %s declares a request body the SDK cannot send", method, path) + } + switch method { + case http.MethodGet: + return api.Get(ctx, path) + case http.MethodPost: + return api.Post(ctx, path, body) + case http.MethodPut: + return api.Put(ctx, path, body) + case http.MethodPatch: + return api.Patch(ctx, path, body) + case http.MethodDelete: + return api.Delete(ctx, path) + default: + return nil, fmt.Errorf("internal error: unsupported method %s", method) + } +} + +// buildRequest places each supplied param where the operation declares it: +// path tokens substituted and escaped, query params encoded (arrays +// Rails-style), body fields collected and wrapped under the operation's +// body key. Unknown params and missing required ones are in-band errors +// naming what the action accepts. +func buildRequest(op *catalog.Operation, params map[string]any) (path string, body any, err error) { + pathParams := map[string]bool{} + queryParams := map[string]bool{} + for _, p := range op.Params { + if p.In == "path" { + pathParams[p.Name] = true + } else { + queryParams[p.Name] = true + } + } + bodyFields := map[string]bool{} + if op.Body != nil { + props, _ := op.Body["properties"].(map[string]any) + for name := range props { + bodyFields[name] = true + } + } + + query := url.Values{} + bodyValues := map[string]any{} + substituted := map[string]string{} + for name, value := range params { + switch { + case pathParams[name]: + str, ok := scalar(value) + if !ok { + return "", nil, fmt.Errorf("param %q for action %q must be a string, number, or boolean, got %T", name, op.Action, value) + } + substituted[name] = url.PathEscape(str) + case queryParams[name]: + if !scalarOrScalarArray(value) { + return "", nil, fmt.Errorf("param %q for action %q must be a scalar or an array of scalars, got %T", name, op.Action, value) + } + addQuery(query, name, value) + case bodyFields[name]: + bodyValues[name] = value + default: + return "", nil, fmt.Errorf("unknown param %q for action %q (accepts: %s)", + name, op.Action, strings.Join(accepted(pathParams, queryParams, bodyFields), ", ")) + } + } + + path = op.Path + for _, token := range catalog.PathTokens(op.Path) { + value, ok := substituted[token] + if !ok || value == "" { + return "", nil, fmt.Errorf("missing required param %q for action %q", token, op.Action) + } + path = strings.ReplaceAll(path, "{"+token+"}", value) + } + + if op.Body != nil { + if required, ok := op.Body["required"].([]any); ok { + for _, name := range required { + field, _ := name.(string) + if _, present := bodyValues[field]; !present { + return "", nil, fmt.Errorf("missing required param %q for action %q", field, op.Action) + } + } + } + } + if len(bodyValues) > 0 { + if op.BodyKey != "" { + body = map[string]any{op.BodyKey: bodyValues} + } else { + body = bodyValues + } + } + + if len(query) > 0 { + path += "?" + query.Encode() + } + return path, body, nil +} + +func accepted(sets ...map[string]bool) []string { + var names []string + for _, set := range sets { + for name := range set { + names = append(names, name) + } + } + sort.Strings(names) + return names +} + +// scalar renders a path or query param value, refusing objects and arrays +// — stringified composites would concatenate garbage into request paths. +// json.Unmarshal delivers numbers as float64; integral values render +// without an exponent. +func scalar(v any) (string, bool) { + switch t := v.(type) { + case string: + return t, true + case float64: + return strconv.FormatFloat(t, 'f', -1, 64), true + case bool: + return strconv.FormatBool(t), true + default: + return "", false + } +} + +// scalarOrScalarArray reports whether v is a JSON scalar or an array of +// scalars — the only shapes the query encoder renders faithfully. +func scalarOrScalarArray(v any) bool { + if items, ok := v.([]any); ok { + for _, item := range items { + if _, ok := scalar(item); !ok { + return false + } + } + return true + } + _, ok := scalar(v) + return ok +} + +// addQuery appends one parameter value to query, arrays in Rails style: +// repeated keys with a [] suffix (board_ids[]=a&board_ids[]=b). +func addQuery(query url.Values, name string, value any) { + switch v := value.(type) { + case []any: + for _, item := range v { + s, _ := scalar(item) + query.Add(name+"[]", s) + } + default: + s, _ := scalar(v) + query.Add(name, s) + } +} + +// nextPage extracts the page number of the rel="next" target from a Link +// header, 0 when absent or unparseable. Fizzy pages by number (Rails +// geared_pagination), so the caller passes it back as the action's page +// parameter. +func nextPage(link string) int { + for part := range strings.SplitSeq(link, ",") { + section := strings.Split(part, ";") + if len(section) < 2 { + continue + } + target := strings.Trim(strings.TrimSpace(section[0]), "<>") + rel := "" + for _, param := range section[1:] { + param = strings.TrimSpace(param) + if value, ok := strings.CutPrefix(param, "rel="); ok { + rel = strings.Trim(value, `"`) + } + } + if rel != "next" { + continue + } + u, err := url.Parse(target) + if err != nil { + return 0 + } + page, err := strconv.Atoi(u.Query().Get("page")) + if err != nil { + return 0 + } + return page + } + return 0 +} diff --git a/internal/mcpserver/dispatch_test.go b/internal/mcpserver/dispatch_test.go new file mode 100644 index 0000000..378a589 --- /dev/null +++ b/internal/mcpserver/dispatch_test.go @@ -0,0 +1,214 @@ +package mcpserver + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/basecamp/fizzy-cli/internal/mcpserver/catalog" +) + +func operation(t *testing.T, domainKey, action string) *catalog.Operation { + t.Helper() + domains, err := catalog.Load() + if err != nil { + t.Fatal(err) + } + for _, d := range domains { + if d.Key != domainKey { + continue + } + op, ok := d.Operation(action) + if !ok { + t.Fatalf("action %q not in domain %q", action, domainKey) + } + return op + } + t.Fatalf("domain %q not in catalog", domainKey) + return nil +} + +// params round-trips arguments through JSON so values arrive exactly as +// the MCP layer delivers them (numbers as float64, arrays as []any). +func params(t *testing.T, jsonText string) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal([]byte(jsonText), &m); err != nil { + t.Fatal(err) + } + return m +} + +func TestBuildRequest(t *testing.T) { + cases := []struct { + name string + domain string + action string + params string + wantPath string + wantBody string // JSON, "" for no body + }{ + { + name: "no params", + domain: "boards", action: "list_boards", + params: `{}`, + wantPath: "/boards", + }, + { + name: "path param substituted and escaped", + domain: "cards", action: "get_card", + params: `{"card_number": "4/2"}`, + wantPath: "/cards/4%2F2", + }, + { + name: "numeric path param renders without exponent", + domain: "cards", action: "get_card", + params: `{"card_number": 42}`, + wantPath: "/cards/42", + }, + { + name: "query params encoded rails style", + domain: "cards", action: "list_cards", + params: `{"board_ids": ["b1", "b2"], "indexed_by": "closed", "page": 2}`, + wantPath: "/cards?board_ids%5B%5D=b1&board_ids%5B%5D=b2&indexed_by=closed&page=2", + }, + { + name: "body wrapped under body key", + domain: "cards", action: "create_card", + params: `{"board_id": "b1", "title": "Add dark mode", "description": "Please"}`, + wantPath: "/boards/b1/cards", + wantBody: `{"card": {"title": "Add dark mode", "description": "Please"}}`, + }, + { + name: "flat body without body key", + domain: "cards", action: "triage_card", + params: `{"card_number": "42", "column_id": "c9"}`, + wantPath: "/cards/42/triage", + wantBody: `{"column_id": "c9"}`, + }, + { + name: "bodiless write", + domain: "cards", action: "close_card", + params: `{"card_number": "42"}`, + wantPath: "/cards/42/closure", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path, body, err := buildRequest(operation(t, tc.domain, tc.action), params(t, tc.params)) + if err != nil { + t.Fatal(err) + } + if path != tc.wantPath { + t.Errorf("path = %q, want %q", path, tc.wantPath) + } + if tc.wantBody == "" { + if body != nil { + t.Errorf("body = %v, want none", body) + } + } else { + got, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + assertJSONEq(t, tc.wantBody, string(got)) + } + }) + } +} + +func TestBuildRequestErrors(t *testing.T) { + cases := []struct { + name string + domain string + action string + params string + want string + }{ + { + name: "missing path param", + domain: "cards", action: "get_card", + params: `{}`, + want: `missing required param "card_number"`, + }, + { + name: "missing required body field", + domain: "cards", action: "triage_card", + params: `{"card_number": "42"}`, + want: `missing required param "column_id"`, + }, + { + name: "unknown param names what the action accepts", + domain: "cards", action: "get_card", + params: `{"card_number": "1", "bogus": true}`, + want: `unknown param "bogus" for action "get_card" (accepts: card_number)`, + }, + { + name: "non-scalar path param", + domain: "cards", action: "get_card", + params: `{"card_number": {"nested": 1}}`, + want: "must be a string, number, or boolean", + }, + { + name: "non-scalar query param", + domain: "cards", action: "list_cards", + params: `{"page": {"nested": 1}}`, + want: "must be a scalar", + }, + { + name: "array of objects in query param", + domain: "cards", action: "list_cards", + params: `{"board_ids": [{"nested": 1}]}`, + want: "must be a scalar", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, _, err := buildRequest(operation(t, tc.domain, tc.action), params(t, tc.params)) + if err == nil { + t.Fatal("buildRequest accepted the params") + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("error = %q, want it to mention %q", err, tc.want) + } + }) + } +} + +func TestNextPage(t *testing.T) { + cases := []struct { + name string + link string + want int + }{ + {"absent", "", 0}, + {"next with page", `; rel="next"`, 2}, + {"prev only", `; rel="prev"`, 0}, + {"prev and next", `; rel="prev", ; rel="next"`, 3}, + {"next without page", `; rel="next"`, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := nextPage(tc.link); got != tc.want { + t.Errorf("nextPage(%q) = %d, want %d", tc.link, got, tc.want) + } + }) + } +} + +func assertJSONEq(t *testing.T, want, got string) { + t.Helper() + var w, g any + if err := json.Unmarshal([]byte(want), &w); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(got), &g); err != nil { + t.Fatal(err) + } + wc, _ := json.Marshal(w) + gc, _ := json.Marshal(g) + // Canonical re-marshal sorts map keys, so equal JSON compares equal. + if string(wc) != string(gc) { + t.Errorf("JSON = %s, want %s", got, want) + } +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go new file mode 100644 index 0000000..151611a --- /dev/null +++ b/internal/mcpserver/server.go @@ -0,0 +1,91 @@ +// Package mcpserver assembles the MCP server behind `fizzy mcp`: the +// hand-written Fizzy tool catalog served through the shared toolkit's +// gateway, dispatching real API calls through the CLI's authenticated, +// account-scoped SDK client. +// +// The generic machinery — domain gateway tools, the {"action", "params"} +// calling convention, read-only filtering, the in-band describe action — +// lives in the shared toolkit at github.com/basecamp/mcp. The catalog in +// internal/mcpserver/catalog is deliberately duplicated from its sibling +// in fizzy-mcp-server (synced by scripts/sync-mcp-catalog.sh, provenance +// recorded). This package supplies the CLI's half: wiring the catalog to +// the gateway and the dispatcher that turns catalog operations into +// fizzy-sdk requests. +package mcpserver + +import ( + "fmt" + "log/slog" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/basecamp/mcp/gateway" + + "github.com/basecamp/fizzy-cli/internal/mcpserver/catalog" +) + +// Name identifies the server in the MCP initialize handshake. The version +// is the CLI's own: `fizzy mcp` is the CLI serving MCP, not a separate +// product. +const Name = "fizzy-cli" + +// Config selects the served tool surface. +type Config struct { + // ReadOnly drops every write action from the catalog and refuses write + // dispatch outright. The served default, matching fizzy-mcp-server's + // posture: writes are an explicit opt-in, paired with a Read+Write + // token — the token's permission is the server-side enforcement, this + // filter is the client-side surface. + ReadOnly bool + // Domains narrows the served domains by key ("boards", "cards", ...). + // Empty means all. Unknown keys are a startup error — fail closed. + Domains []string + // Version is the CLI version reported in the initialize handshake. + Version string +} + +// Server wraps the toolkit gateway serving the catalog, dispatching +// through the CLI's SDK clients. +type Server struct { + gw *gateway.Server + version string +} + +// New validates the catalog and hands it to the gateway, which applies +// the config's domain and read-only filters. Tool calls dispatch through +// account (account-scoped operations) and root (the few unscoped ones, +// like get_identity). +func New(account, root API, cfg Config) (*Server, error) { + if account == nil || root == nil { + return nil, fmt.Errorf("mcpserver: account and root API clients are required") + } + + domains, err := catalog.Load() + if err != nil { + return nil, fmt.Errorf("load catalog: %w", err) + } + + srv := &Server{version: cfg.Version} + gw, err := gateway.New(catalog.GatewayDomains(domains), gateway.Config{ + ReadOnly: cfg.ReadOnly, + Domains: cfg.Domains, + Handler: dispatcher{account: account, root: root}.handle, + }) + if err != nil { + return nil, err + } + srv.gw = gw + + return srv, nil +} + +// Domains returns the served domains. +func (s *Server) Domains() []gateway.Domain { + return s.gw.Domains() +} + +// BuildMCPServer constructs the SDK MCP server with one gateway tool per +// served domain. +func (s *Server) BuildMCPServer(logger *slog.Logger) *mcp.Server { + return s.gw.BuildMCPServer(&mcp.Implementation{Name: Name, Version: s.version}, logger) +} diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go new file mode 100644 index 0000000..0912d86 --- /dev/null +++ b/internal/mcpserver/server_test.go @@ -0,0 +1,318 @@ +// Wire tests: a real MCP client drives the server over in-memory +// transports (the toolkit's mcptest harness), and dispatched actions land +// on a fake Fizzy (httptest) through a real fizzy-sdk client — so the +// asserted HTTP surface (bearer auth, account scoping, query encoding, +// body wrapping) is exactly what `fizzy mcp` sends, and the played-back +// response shapes (JSON, 204s, 201 Locations, Link pagination) are +// rendered the way the API produces them. +package mcpserver + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + + fizzy "github.com/basecamp/fizzy-sdk/go/pkg/fizzy" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/basecamp/mcp/mcptest" +) + +const testAccount = "897362094" + +// fakeFizzy runs an httptest server that checks auth on every request and +// serves the registered handlers. +func fakeFizzy(t *testing.T, mux *http.ServeMux) *httptest.Server { + t.Helper() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + t.Errorf("Authorization = %q, want the CLI's token", got) + } + if got := r.Header.Get("Accept"); got != "application/json" { + t.Errorf("Accept = %q", got) + } + mux.ServeHTTP(w, r) + })) + t.Cleanup(ts.Close) + return ts +} + +// connect builds a server against the fake Fizzy — through a real SDK +// client bound to the test account — and connects a real MCP client to +// it. Writes are enabled unless the config says otherwise, so dispatch +// tests reach write actions; read-only is the served default and has its +// own tests. +func connect(t *testing.T, cfg Config, upstream *httptest.Server) (*Server, *mcp.ClientSession) { + t.Helper() + + baseURL := "http://127.0.0.1:0" // no upstream: any dispatch attempt fails loudly + if upstream != nil { + baseURL = upstream.URL + } + sdk := fizzy.NewClient(&fizzy.Config{BaseURL: baseURL}, &fizzy.StaticTokenProvider{Token: "test-token"}) + + srv, err := New(sdk.ForAccount(testAccount), sdk, cfg) + if err != nil { + t.Fatal(err) + } + return srv, mcptest.Connect(t, srv.BuildMCPServer(slog.New(slog.DiscardHandler))) +} + +func callJSON(t *testing.T, session *mcp.ClientSession, tool string, args map[string]any) map[string]any { + t.Helper() + text, isError := mcptest.CallText(t, session, tool, args) + if isError { + t.Fatalf("call failed: %s", text) + } + var payload map[string]any + if err := json.Unmarshal([]byte(text), &payload); err != nil { + t.Fatalf("result %q: %v", text, err) + } + return payload +} + +func TestListToolsServesCatalog(t *testing.T) { + _, session := connect(t, Config{}, nil) + tools := mcptest.ListTools(t, session) + + for _, name := range []string{"fizzy_identity", "fizzy_boards", "fizzy_columns", "fizzy_cards", "fizzy_comments", "fizzy_steps", "fizzy_tags", "fizzy_users"} { + tool, ok := tools[name] + if !ok { + t.Errorf("missing tool %q", name) + continue + } + if !strings.Contains(tool.Description, "ACTIONS") { + t.Errorf("tool %q description lacks its action list", name) + } + } + if len(tools) != 8 { + t.Errorf("tools/list returned %d tools, want 8", len(tools)) + } + + // identity is all reads; cards is not. + if !tools["fizzy_identity"].Annotations.ReadOnlyHint { + t.Error("fizzy_identity must hint read-only") + } + if tools["fizzy_cards"].Annotations.ReadOnlyHint { + t.Error("fizzy_cards must not hint read-only with writes served") + } +} + +func TestReadOnlyFiltersWriteActions(t *testing.T) { + srv, session := connect(t, Config{ReadOnly: true}, nil) + + // Every domain keeps at least one read, so all eight tools survive, + // and every survivor is all-read. + tools := mcptest.ListTools(t, session) + if len(tools) != 8 { + t.Fatalf("tools/list returned %d tools, want 8", len(tools)) + } + for name, tool := range tools { + if !tool.Annotations.ReadOnlyHint { + t.Errorf("tool %q must be read-only", name) + } + } + for _, d := range srv.Domains() { + if slices.Contains(d.ActionNames(), "create_card") { + t.Error("read-only server still serves create_card") + } + } + + // A filtered write action is gone from the catalog, so dispatch + // refuses it in-band even when a client ignores the schema. + text, isError := mcptest.CallText(t, session, "fizzy_cards", map[string]any{"action": "create_card"}) + if !isError { + t.Fatalf("write action succeeded on read-only server: %s", text) + } + if !strings.Contains(text, "unknown action") { + t.Errorf("refusal = %q", text) + } +} + +func TestDescribeServesOperationSchema(t *testing.T) { + _, session := connect(t, Config{}, nil) + + op := callJSON(t, session, "fizzy_cards", map[string]any{ + "action": "describe", + "params": map[string]any{"action": "triage_card"}, + }) + if op["method"] != "POST" || op["path"] != "/cards/{card_number}/triage" { + t.Errorf("describe = %v", op) + } +} + +func TestUnknownDomainFailsClosed(t *testing.T) { + sdk := fizzy.NewClient(&fizzy.Config{BaseURL: "http://127.0.0.1:0"}, &fizzy.StaticTokenProvider{Token: "test-token"}) + _, err := New(sdk.ForAccount(testAccount), sdk, Config{Domains: []string{"cards", "nope"}}) + if err == nil || !strings.Contains(err.Error(), `unknown domain "nope"`) { + t.Errorf("err = %v, want unknown domain failure", err) + } +} + +func TestNarrowedDomainsServeOnlyThose(t *testing.T) { + _, session := connect(t, Config{Domains: []string{"cards"}}, nil) + tools := mcptest.ListTools(t, session) + if len(tools) != 1 { + t.Fatalf("tools = %d, want just fizzy_cards", len(tools)) + } + if _, ok := tools["fizzy_cards"]; !ok { + t.Fatal("fizzy_cards missing") + } +} + +func TestServerRequiresClients(t *testing.T) { + if _, err := New(nil, nil, Config{}); err == nil { + t.Error("nil clients did not error") + } +} + +func TestDispatchGetIsAccountScoped(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /"+testAccount+"/cards/42", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"number": 42, "title": "First!"}`)) + }) + _, session := connect(t, Config{}, fakeFizzy(t, mux)) + + card := callJSON(t, session, "fizzy_cards", map[string]any{ + "action": "get_card", + "params": map[string]any{"card_number": 42}, + }) + if card["title"] != "First!" { + t.Errorf("card = %v", card) + } +} + +func TestDispatchEncodesQueryRailsStyle(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /"+testAccount+"/cards", func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + if got := query["board_ids[]"]; !slices.Equal(got, []string{"b1", "b2"}) { + t.Errorf("board_ids[] = %v", got) + } + if query.Get("indexed_by") != "closed" || query.Get("page") != "2" { + t.Errorf("query = %v", query) + } + _, _ = w.Write([]byte(`[]`)) + }) + _, session := connect(t, Config{}, fakeFizzy(t, mux)) + + text, isError := mcptest.CallText(t, session, "fizzy_cards", map[string]any{ + "action": "list_cards", + "params": map[string]any{ + "board_ids": []any{"b1", "b2"}, + "indexed_by": "closed", + "page": 2, + }, + }) + if isError || text != `[]` { + t.Errorf("result = %q (isError=%v)", text, isError) + } +} + +func TestDispatchWrapsBodyAndFollowsCreatedLocation(t *testing.T) { + location := "/" + testAccount + "/cards/7.json" + mux := http.NewServeMux() + mux.HandleFunc("POST /"+testAccount+"/boards/b1/cards", func(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + var body map[string]any + if err := json.Unmarshal(data, &body); err != nil { + t.Fatalf("body %q: %v", data, err) + } + card, _ := body["card"].(map[string]any) + if card["title"] != "Add dark mode" || card["description"] != "Please" { + t.Errorf("body = %s", data) + } + w.Header().Set("Location", location) + w.WriteHeader(http.StatusCreated) + }) + mux.HandleFunc("GET "+location, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"number": 7, "title": "Add dark mode"}`)) + }) + _, session := connect(t, Config{}, fakeFizzy(t, mux)) + + card := callJSON(t, session, "fizzy_cards", map[string]any{ + "action": "create_card", + "params": map[string]any{"board_id": "b1", "title": "Add dark mode", "description": "Please"}, + }) + if card["number"] != float64(7) { + t.Errorf("create must return the created resource via its Location, got %v", card) + } +} + +func TestDispatchReportsBodilessSuccess(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /"+testAccount+"/cards/42/triage", func(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + var body map[string]any + if err := json.Unmarshal(data, &body); err != nil || body["column_id"] != "c9" { + t.Errorf("body = %s", data) + } + w.WriteHeader(http.StatusNoContent) + }) + _, session := connect(t, Config{}, fakeFizzy(t, mux)) + + result := callJSON(t, session, "fizzy_cards", map[string]any{ + "action": "triage_card", + "params": map[string]any{"card_number": "42", "column_id": "c9"}, + }) + if result["ok"] != true || result["status"] != float64(204) { + t.Errorf("result = %v", result) + } +} + +func TestDispatchSurfacesPagination(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /"+testAccount+"/cards", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Link", `; rel="next"`) + _, _ = w.Write([]byte(`[{"number": 1}]`)) + }) + _, session := connect(t, Config{}, fakeFizzy(t, mux)) + + page := callJSON(t, session, "fizzy_cards", map[string]any{"action": "list_cards"}) + if page["next_page"] != float64(2) { + t.Errorf("next_page = %v", page["next_page"]) + } + if data, ok := page["data"].([]any); !ok || len(data) != 1 { + t.Errorf("data = %v", page["data"]) + } +} + +func TestDispatchSurfacesAPIErrorsInBand(t *testing.T) { + mux := http.NewServeMux() // no routes: everything 404s + _, session := connect(t, Config{}, fakeFizzy(t, mux)) + + text, isError := mcptest.CallText(t, session, "fizzy_cards", map[string]any{ + "action": "get_card", + "params": map[string]any{"card_number": "999"}, + }) + if !isError { + t.Fatalf("missing card did not error: %s", text) + } + if !strings.Contains(text, "404") { + t.Errorf("error = %q, want the HTTP status surfaced", text) + } +} + +func TestIdentityIsUnscoped(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /my/identity", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"accounts": [{"name": "37signals", "slug": "/` + testAccount + `"}]}`)) + }) + _, session := connect(t, Config{}, fakeFizzy(t, mux)) + + payload := callJSON(t, session, "fizzy_identity", map[string]any{"action": "get_identity"}) + if _, ok := payload["accounts"]; !ok { + t.Errorf("payload = %v", payload) + } +} diff --git a/scripts/sync-mcp-catalog.sh b/scripts/sync-mcp-catalog.sh new file mode 100755 index 0000000..ee9fd87 --- /dev/null +++ b/scripts/sync-mcp-catalog.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# Sync the Fizzy MCP catalog from its sibling in fizzy-mcp-server. +# +# The hand-written catalog (domain specs, operations, rendering) is +# deliberately duplicated between fizzy-mcp-server (the hosted/HTTP story) +# and this CLI (the local stdio story), per the basecamp/mcp toolkit's +# two-instance-by-duplication convention: machinery proven in both moves +# to the toolkit, and until then each repo carries a full copy. This +# script copies the sibling's catalog package and snapshot verbatim and +# records provenance, so drift shows up as a reviewed diff here rather +# than as silently divergent tool surfaces. +# +# fizzy-mcp-server is a private repo, so this script is for maintainers +# with a checkout; the vendored copy keeps CI and outside builds hermetic. +# +# Usage: scripts/sync-mcp-catalog.sh [path-to-fizzy-mcp-server-checkout] +set -eu + +sibling="${1:-../fizzy-mcp-server}" +src="$sibling/internal/catalog" +dest="$(dirname "$0")/../internal/mcpserver/catalog" + +if [ ! -f "$src/catalog.go" ]; then + echo "error: $src/catalog.go not found (pass a fizzy-mcp-server checkout)" >&2 + exit 1 +fi + +commit=$(git -C "$sibling" rev-parse HEAD) +if ! git -C "$sibling" diff --quiet HEAD -- internal/catalog 2>/dev/null; then + commit="$commit-dirty" +fi + +cp "$src/catalog.go" "$src/domains.go" "$dest/" +cp "$src/testdata/catalog_snapshot.txt" "$dest/testdata/" + +cat > "$dest/PROVENANCE.json" < Date: Fri, 28 Aug 2026 04:43:27 -0700 Subject: [PATCH 2/3] Sync the catalog: boards, tags, and users listings paginate Codex review caught that list_boards, list_tags, and list_users answered next_page without accepting a page param, stranding everything past page one. Verified against the fizzy app's controllers (geared pagination on boards#index, tags#index, users#index; list_columns and list_steps render their full collections and stay unpaginated), fixed in the catalog's home repo (fizzy-mcp-server@df27419), and synced here by scripts/sync-mcp-catalog.sh. --- internal/mcpserver/catalog/PROVENANCE.json | 2 +- internal/mcpserver/catalog/domains.go | 9 ++-- .../catalog/testdata/catalog_snapshot.txt | 48 ++++++++++++++++--- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/internal/mcpserver/catalog/PROVENANCE.json b/internal/mcpserver/catalog/PROVENANCE.json index c5f66ad..d6d9de8 100644 --- a/internal/mcpserver/catalog/PROVENANCE.json +++ b/internal/mcpserver/catalog/PROVENANCE.json @@ -1,6 +1,6 @@ { "source": "github.com/basecamp/fizzy-mcp-server", - "commit": "81f7c4a980b9eb8be1ef9d83a4c4d5d939d3afa6", + "commit": "df2741916156bd03109dca6a4a52247e597b5fd0", "path": "internal/catalog", "files": ["catalog.go", "domains.go", "testdata/catalog_snapshot.txt"], "synced_by": "scripts/sync-mcp-catalog.sh" diff --git a/internal/mcpserver/catalog/domains.go b/internal/mcpserver/catalog/domains.go index 4a3a765..c447ddf 100644 --- a/internal/mcpserver/catalog/domains.go +++ b/internal/mcpserver/catalog/domains.go @@ -90,8 +90,9 @@ var Domains = []*Domain{ Params: []Param{pathParam("board_id", "The board ID"), pageParam()}, }, { - Action: "list_boards", Method: "GET", Path: "/boards", ReadOnly: true, + Action: "list_boards", Method: "GET", Path: "/boards", ReadOnly: true, Paginated: true, Summary: "List the boards you have access to", + Params: []Param{pageParam()}, }, { Action: "publish_board", Method: "POST", Path: "/boards/{board_id}/publication", @@ -377,8 +378,9 @@ var Domains = []*Domain{ Blurb: "Tags: the labels applied to cards, account-wide. Apply or remove them with the cards tool's toggle_tag.", Operations: []*Operation{ { - Action: "list_tags", Method: "GET", Path: "/tags", ReadOnly: true, + Action: "list_tags", Method: "GET", Path: "/tags", ReadOnly: true, Paginated: true, Summary: "List the account's tags, alphabetically", + Params: []Param{pageParam()}, }, }, }, @@ -393,8 +395,9 @@ var Domains = []*Domain{ Params: []Param{pathParam("user_id", "The user ID")}, }, { - Action: "list_users", Method: "GET", Path: "/users", ReadOnly: true, + Action: "list_users", Method: "GET", Path: "/users", ReadOnly: true, Paginated: true, Summary: "List the account's active users", + Params: []Param{pageParam()}, }, }, }, diff --git a/internal/mcpserver/catalog/testdata/catalog_snapshot.txt b/internal/mcpserver/catalog/testdata/catalog_snapshot.txt index 879dda3..0554a55 100644 --- a/internal/mcpserver/catalog/testdata/catalog_snapshot.txt +++ b/internal/mcpserver/catalog/testdata/catalog_snapshot.txt @@ -48,7 +48,7 @@ ACTIONS (RO = read-only): - delete_board: Delete a board (board administrators only) - get_board (RO): Get one board - list_accesses (RO, paginated): List account users with their access and involvement for a board -- list_boards (RO): List the boards you have access to +- list_boards (RO, paginated): List the boards you have access to - publish_board: Publish a board to a shareable public link (administrators only) - unpublish_board: Unpublish a board, removing public access (administrators only) - update_board: Update a board (administrators only) @@ -189,7 +189,19 @@ ACTIONS (RO = read-only): "method": "GET", "path": "/boards", "summary": "List the boards you have access to", - "readonly": true + "readonly": true, + "paginated": true, + "params": [ + { + "name": "page", + "in": "query", + "description": "Page number; responses include next_page when more results exist.", + "schema": { + "description": "Page number", + "type": "integer" + } + } + ] } ---- describe publish_board ---- { @@ -1616,7 +1628,7 @@ Gateway tool: call with {"action": "...", "params": {...}}. Call {"action": "describe", "params": {"action": "NAME"}} for an action's full parameter schema. ACTIONS (RO = read-only): -- list_tags (RO): List the account's tags, alphabetically +- list_tags (RO, paginated): List the account's tags, alphabetically ---- input schema ---- { "additionalProperties": false, @@ -1644,7 +1656,19 @@ ACTIONS (RO = read-only): "method": "GET", "path": "/tags", "summary": "List the account's tags, alphabetically", - "readonly": true + "readonly": true, + "paginated": true, + "params": [ + { + "name": "page", + "in": "query", + "description": "Page number; responses include next_page when more results exist.", + "schema": { + "description": "Page number", + "type": "integer" + } + } + ] } ==== TOOL fizzy_users ==== Users: the people in the Fizzy account. Assign them to cards with the cards tool's toggle_assignment. @@ -1654,7 +1678,7 @@ Call {"action": "describe", "params": {"action": "NAME"}} for an action's full p ACTIONS (RO = read-only): - get_user (RO): Get one user -- list_users (RO): List the account's active users +- list_users (RO, paginated): List the account's active users ---- input schema ---- { "additionalProperties": false, @@ -1703,5 +1727,17 @@ ACTIONS (RO = read-only): "method": "GET", "path": "/users", "summary": "List the account's active users", - "readonly": true + "readonly": true, + "paginated": true, + "params": [ + { + "name": "page", + "in": "query", + "description": "Page number; responses include next_page when more results exist.", + "schema": { + "description": "Page number", + "type": "integer" + } + } + ] } From 9d4c88977959f82c213e51267979e744e6aff47e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 28 Aug 2026 04:52:31 -0700 Subject: [PATCH 3/3] Address Copilot review: neutral identity doc, complete domain list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - get_identity's describe payload promised FIZZY_ACCOUNT-or-discovery, fizzy-mcp-server's startup behavior — here the account comes from CLI profile config, so agents were told a fallback exists that this server does not have. Reworded consumer-neutrally in the catalog's home repo (fizzy-mcp-server@4bef952) and synced. - fizzy mcp --help and the README named six of the eight served domains; now all eight. --- README.md | 4 ++-- internal/commands/mcp.go | 4 ++-- internal/mcpserver/catalog/PROVENANCE.json | 2 +- internal/mcpserver/catalog/domains.go | 2 +- internal/mcpserver/catalog/testdata/catalog_snapshot.txt | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 61c5f8c..f0fb987 100644 --- a/README.md +++ b/README.md @@ -236,8 +236,8 @@ fizzy config explain --profile acme ## MCP server `fizzy mcp` runs an MCP (Model Context Protocol) server on stdin/stdout, serving Fizzy -boards, cards, comments, steps, tags, and users as tools backed by your signed-in -account — the same credentials every other command uses. Register it with any MCP +boards, columns, cards, comments, steps, tags, users, and your identity as tools +backed by your signed-in account — the same credentials every other command uses. Register it with any MCP client as a stdio server: ```bash diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go index 19f5d21..d3553e7 100644 --- a/internal/commands/mcp.go +++ b/internal/commands/mcp.go @@ -25,8 +25,8 @@ var mcpCmd = &cobra.Command{ Use: "mcp", Short: "Serve Fizzy to MCP clients over stdio", Long: "Run an MCP (Model Context Protocol) server on stdin/stdout, serving Fizzy\n" + - "boards, cards, comments, steps, tags, and users as tools backed by your\n" + - "signed-in account.\n\n" + + "boards, columns, cards, comments, steps, tags, users, and your identity as\n" + + "tools backed by your signed-in account.\n\n" + "Read-only by default; --writes serves write actions too (pair with a\n" + "Read+Write access token). Register it with an MCP client as a stdio\n" + "server, e.g.:\n\n" + diff --git a/internal/mcpserver/catalog/PROVENANCE.json b/internal/mcpserver/catalog/PROVENANCE.json index d6d9de8..7d05d76 100644 --- a/internal/mcpserver/catalog/PROVENANCE.json +++ b/internal/mcpserver/catalog/PROVENANCE.json @@ -1,6 +1,6 @@ { "source": "github.com/basecamp/fizzy-mcp-server", - "commit": "df2741916156bd03109dca6a4a52247e597b5fd0", + "commit": "4bef95299bde8fd6b88c5627d8fa6b4529abdef7", "path": "internal/catalog", "files": ["catalog.go", "domains.go", "testdata/catalog_snapshot.txt"], "synced_by": "scripts/sync-mcp-catalog.sh" diff --git a/internal/mcpserver/catalog/domains.go b/internal/mcpserver/catalog/domains.go index c447ddf..5542813 100644 --- a/internal/mcpserver/catalog/domains.go +++ b/internal/mcpserver/catalog/domains.go @@ -55,7 +55,7 @@ var Domains = []*Domain{ { Action: "get_identity", Method: "GET", Path: "/my/identity", ReadOnly: true, Unscoped: true, Summary: "List the accounts the token can access, with your user record in each", - Doc: "Each account carries a slug; account-scoped actions use it automatically when FIZZY_ACCOUNT is set, or discover it here when exactly one account exists.", + Doc: "Each account carries a slug naming it in API paths. Account-scoped actions run against the account the server is configured for; this action shows every account the token can reach.", }, }, }, diff --git a/internal/mcpserver/catalog/testdata/catalog_snapshot.txt b/internal/mcpserver/catalog/testdata/catalog_snapshot.txt index 0554a55..f54073c 100644 --- a/internal/mcpserver/catalog/testdata/catalog_snapshot.txt +++ b/internal/mcpserver/catalog/testdata/catalog_snapshot.txt @@ -33,7 +33,7 @@ ACTIONS (RO = read-only): "method": "GET", "path": "/my/identity", "summary": "List the accounts the token can access, with your user record in each", - "doc": "Each account carries a slug; account-scoped actions use it automatically when FIZZY_ACCOUNT is set, or discover it here when exactly one account exists.", + "doc": "Each account carries a slug naming it in API paths. Account-scoped actions run against the account the server is configured for; this action shows every account the token can reach.", "readonly": true, "unscoped": true }