From a928222ace4e3aaa61998c0bc1eb7be08ffd9658 Mon Sep 17 00:00:00 2001 From: xarmian Date: Fri, 1 May 2026 18:36:29 -0400 Subject: [PATCH] feat(mcp): add pad://workspaces top-level resource (TASK-974) (#358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes the workspace catalog to a static MCP resource so hosts can prefetch it once at session start instead of forcing a pad_workspace.list tool call per turn. URI: pad://workspaces Shape: JSON array of {slug, name, updated_at, default} entries — exactly the shape `pad workspace list --format json` emits, so the resource handler and classifyExecError's available_workspaces side channel consume the same source of truth. Implementation: - internal/mcp/resources.go: add WorkspacesURI constant + readWorkspaces handler. Registered via AddResource (not AddResourceTemplate) since the URI is parameter-free and lives in resources/list. Tests: - TestReadWorkspaces_DispatchesWorkspaceListJSON: end-to-end resource/read round-trip through HandleMessage; asserts the fetcher saw the right CLI args. - TestReadWorkspaces_RejectsWrongURI: defensive guard against URI/handler binding drift in future refactors. - TestReadWorkspaces_PropagatesFetcherError: fetcher errors surface cleanly to the MCP client instead of being swallowed. Out of scope (per task description): - Live updates / resources/subscribe — future enhancement. - Per-collection resource (pad://workspace/{ws}/collections/{slug}) — flat list stays for now. Parent: TASK-974 → PLAN-969. --- internal/mcp/resources.go | 41 ++++++++++++++++++++ internal/mcp/resources_test.go | 69 ++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go index 694f5b00..f619247d 100644 --- a/internal/mcp/resources.go +++ b/internal/mcp/resources.go @@ -24,6 +24,12 @@ const ( resourceKindCollect = "collections" ) +// WorkspacesURI is the canonical URI of the top-level workspace list +// resource. Like pad://_meta/version, it lives outside the +// pad://workspace/{ws}/... namespace because it's server-wide rather +// than scoped to a specific workspace. +const WorkspacesURI = "pad://workspaces" + // ResourceFetcher executes a pad CLI invocation and returns its // stdout. Used by the resource layer where the contract is "fetch raw // output"; shape conversion to MCP ResourceContents happens in the @@ -130,6 +136,28 @@ func RegisterResources(srv *server.MCPServer, fetcher ResourceFetcher, rootFlags ), r.readCollections, ) + + // Top-level workspace list (TASK-974). Static resource — no + // parameters in the URI, so it lives in resources/list rather + // than resources/templates/list. Lets MCP hosts prefetch the + // workspace catalog at session start so subsequent tool calls + // can pass `workspace=` without an extra round-trip. + srv.AddResource( + mcp.NewResource( + WorkspacesURI, + "pad workspaces", + mcp.WithResourceDescription( + "List of workspaces visible to the current user. "+ + "Each entry: {slug, name, updated_at, default}. "+ + "`default: true` flags the CWD-linked workspace "+ + "(local stdio only) so agents can prefer it. "+ + "Cheaper than pad_workspace.action: list when the "+ + "host can prefetch.", + ), + mcp.WithMIMEType(jsonMIMEType), + ), + r.readWorkspaces, + ) } // readItem handles pad://workspace/{ws}/items/{ref}. @@ -257,6 +285,19 @@ func (r *resources) readDashboard(ctx context.Context, req mcp.ReadResourceReque []string{"project", "dashboard", "--workspace", ws, "--format", "json"}) } +// readWorkspaces handles pad://workspaces — the top-level workspace +// list (TASK-974). Shells out to `pad workspace list --format json` +// so the resource shape stays in lockstep with the CLI's JSON output +// (and with classifyExecError's available_workspaces enrichment, +// which uses the same path). +func (r *resources) readWorkspaces(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + if req.Params.URI != WorkspacesURI { + return nil, fmt.Errorf("resource %q is not the workspaces URI", req.Params.URI) + } + return r.fetchAsResource(ctx, req.Params.URI, jsonMIMEType, + []string{"workspace", "list", "--format", "json"}) +} + // readCollections handles pad://workspace/{ws}/collections. func (r *resources) readCollections(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { ws, kind, arg, err := parsePadURI(req.Params.URI) diff --git a/internal/mcp/resources_test.go b/internal/mcp/resources_test.go index 54b3640f..a0775409 100644 --- a/internal/mcp/resources_test.go +++ b/internal/mcp/resources_test.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "encoding/json" "errors" "strings" "testing" @@ -77,6 +78,74 @@ func TestRegisterResources_AdvertisesAllFourTemplates(t *testing.T) { // might require WithResourceCapabilities up-front. } +// TestReadWorkspaces_DispatchesWorkspaceListJSON exercises the new +// pad://workspaces resource (TASK-974). It must shell out to the +// JSON output added to `pad workspace list` in PR #357 so the +// resource shape stays in lockstep with classifyExecError's +// available_workspaces enrichment (both consume the same JSON). +func TestReadWorkspaces_DispatchesWorkspaceListJSON(t *testing.T) { + body := `[{"slug":"docapp","name":"Pad","default":true},{"slug":"pad-web","name":"Marketing"}]` + fetcher := &fakeFetcher{stdout: body} + srv := server.NewMCPServer("t", "1", server.WithResourceCapabilities(false, false)) + RegisterResources(srv, fetcher, nil) + + got := readResourceJSON(t, srv, WorkspacesURI) + if got != body { + t.Errorf("body = %q, want %q", got, body) + } + wantArgs := []string{"workspace", "list", "--format", "json"} + if !equalSlice(fetcher.gotArgs, wantArgs) { + t.Errorf("fetched args = %v, want %v", fetcher.gotArgs, wantArgs) + } +} + +// TestReadWorkspaces_RejectsWrongURI confirms the handler validates +// the URI it was bound to. Without this guard a future refactor that +// reuses readWorkspaces under a different URI registration would +// silently succeed. +func TestReadWorkspaces_RejectsWrongURI(t *testing.T) { + r := &resources{fetcher: &fakeFetcher{stdout: `[]`}} + req := mcp.ReadResourceRequest{} + req.Params.URI = "pad://wrong/uri" + _, err := r.readWorkspaces(context.Background(), req) + if err == nil { + t.Errorf("expected error for non-WorkspacesURI request") + } +} + +// TestReadWorkspaces_PropagatesFetcherError ensures the handler +// surfaces fetcher failures (e.g. CLI exit non-zero, no auth) as +// proper resource-read errors rather than swallowing them. MCP +// clients display these directly. +func TestReadWorkspaces_PropagatesFetcherError(t *testing.T) { + fetcher := &fakeFetcher{err: errors.New("not authenticated")} + srv := server.NewMCPServer("t", "1", server.WithResourceCapabilities(false, false)) + RegisterResources(srv, fetcher, nil) + + // Drive resources/read directly via HandleMessage; assert error + // surfaces, not stdout. + reqJSON := []byte(`{ + "jsonrpc": "2.0", + "id": 1, + "method": "resources/read", + "params": { "uri": "` + WorkspacesURI + `" } + }`) + resp := srv.HandleMessage(context.Background(), reqJSON) + if resp == nil { + t.Fatalf("HandleMessage returned nil") + } + // The mcp-go server packages the handler's error into a JSON-RPC + // error response. We just need to ensure the call wasn't a + // success — a string-search on the JSON output is sufficient. + enc, err := json.Marshal(resp) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(enc), "not authenticated") { + t.Errorf("expected fetcher error to surface; got %s", enc) + } +} + func TestReadItem_DispatchesJSONAndComposesMarkdown(t *testing.T) { // Codex review (round 1) caught: `pad item show --format markdown` // emits only item.Content (no ref/title/fields). The MCP resource