feat(mcp): add pad://workspaces top-level resource (TASK-974) (#358)

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.
This commit is contained in:
xarmian
2026-05-01 18:36:29 -04:00
committed by GitHub
parent 1e94fcbd9d
commit a928222ace
2 changed files with 110 additions and 0 deletions
+41
View File
@@ -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=<slug>` 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)
+69
View File
@@ -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