mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 19:32:10 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user