feat(mcp): pad_library catalog tool + ToolSurfaceVersion 0.5 (TASK-1563) (#615)

* feat(mcp): pad_library catalog tool + ToolSurfaceVersion 0.5 (TASK-1563)

MCP catalog wiring for PLAN-1560 (`pad_library` MCP tool + matching CLI
surface). Closes IDEA-1514 — pure-MCP agents (notably the /pad onboard
playbook from PLAN-1496) can now browse and activate library entries
without shelling out.

## New tool

`pad_library` joins the v0.5 catalog as the ninth resource × action tool.
Three actions, all passThrough to the `pad library` CLI:

- `list`     — Browse conventions + playbooks. Defaults to summary mode
               for playbooks (compact bodies via the ?summary=true
               endpoint flag); conventions always carry full content.
               Optional type / category / full inputs.
- `get`      — Full body of one entry by exact title. Conventions-first
               precedence mirrors `activate`.
- `activate` — Create a workspace item from a library entry by title.

`Workspace: true` on the tool — list/get ignore it; activate validates
and uses it. The schema-level declaration gives activate automatic
pad_set_workspace session-default resolution (same precedent as
pad_meta's mixed-workspace actions).

## Dispatcher extensions

- `dispatchLibraryList` forwards `category` to BOTH endpoints and
  passes `summary=true` to the playbook endpoint by default (unless
  input.full=true). MCP-default summary mode keeps agent context
  budgets tight; CLI default already aligned in TASK-1562.
- `library get` added to the routeTable as a clean GET to
  /api/v1/library/entry with `title` mapped to the query string.
  Cleaner than another explicit dispatcher case — matches
  playbook list / playbook show shape.

## Version bump

ToolSurfaceVersion bumped from 0.4 → 0.5. Pure addition; no existing
tool/action/param/bootstrap shapes changed. Backwards-compatible for
any v0.4 consumer that doesn't enumerate the new tool. Documented in
version.go with the same comment-block structure as prior bumps.

## Test coverage

- catalog_readonly_test.go — pad_library added to the want{} map; three
  library action → cmdPath entries in expected{}; library list / get /
  activate added to liveCmdhelpDoc stubs.
- dispatch_http_project_test.go — 4-case table test (defaults, category,
  full=true, category+full) pins category/summary query-param forwarding;
  library get routing test confirms the routeTable entry resolves.

## Live MCP verification

- `initialize` handshake advertises padToolSurface.version=0.5.
- `pad_meta version` returns tool_surface_version=0.5.
- `pad_library list type=playbooks category=agent-workflows` returns
  4 playbooks in summary mode (content stripped, summary populated,
  invocation_slug + arguments present).
- `pad_library get title='Ship tasks'` returns
  {type: playbook, playbook: {…, content (9512 chars), invocation_slug: ship}}.

Parent: PLAN-1560. Unblocks TASK-1564 (cleanups).

* fix(onboard): update playbook body to use pad_library MCP tool per Codex review (round 1)

Codex P2 on PR #615: the /pad onboard playbook body in
internal/collections/playbook_library_onboard.go still told MCP-only
agents that the library catalog was "not yet exposed as an MCP tool"
and to work from memory — directly contradicting the pad_library tool
this PR just landed and breaking the main advertised consumer of the
new surface.

Updated step B3 (conventions) to mention both surfaces side-by-side
(`pad library list --type conventions` / `pad_library` with
`action: list, type: conventions`), and rewrote step B5 (playbooks)
the same way so the activate path doesn't drift either.

Pre-PLAN-1560 IDEA-1514 reference removed from the body — the idea
is now closed.

No test pins the playbook body content; `make check` passes; the
playbook seed still validates against the playbooks collection schema
since trigger/scope/invocation_slug/arguments are unchanged.

Closes the onboard-side scope of TASK-1564 (stale dispatch_http_slice4
hint + CHANGELOG still pending there).
This commit is contained in:
xarmian
2026-05-21 19:18:19 -04:00
committed by GitHub
parent 9a47c36ea6
commit 6433cc51ea
7 changed files with 288 additions and 10 deletions
@@ -117,7 +117,12 @@ Iterate until the user's happy. Then create the collections — one ` + "`pad co
The conventions collection already exists (blank template ships it). Now you fill it.
Browse the convention library. If the agent has a shell, run ` + "`pad library list --type conventions`" + ` (and ` + "`pad library list --type conventions --category <name>`" + ` to filter). If the agent is MCP-only (no shell), the library catalog is not yet exposed as an MCP tool work from your own knowledge of common conventions for the workspace's domain, propose them by name to the user, and have the user paste any library bodies they want as the starting text. (Adding an MCP-side library-browse action is tracked in IDEA-1514.) For each convention that's plausibly relevant, READ ITS BODY, then rewrite using this project's actual commands. Examples:
Browse the convention library:
- **CLI:** ` + "`pad library list --type conventions`" + ` (filter with ` + "`--category <name>`" + `; full body of one entry via ` + "`pad library get \"<title>\"`" + `).
- **MCP:** ` + "`pad_library`" + ` with ` + "`action: list, type: conventions`" + ` (and optional ` + "`category`" + `). Full body of one entry via ` + "`pad_library`" + ` with ` + "`action: get, title: \"<title>\"`" + `. Same shape on both surfaces. Closed by PLAN-1560 (IDEA-1514).
For each convention that's plausibly relevant, READ ITS BODY, then rewrite using this project's actual commands. Examples:
- Library has "Run tests before completing tasks." If the project is Go with a Makefile, your version says "Run ` + "`make test`" + ` before marking a task done. If the build fails, fix it before merging."
- Library has "Conventional commit format." If the project's existing commits don't follow that style, ASK the user before activating it maybe they don't want it.
@@ -137,9 +142,12 @@ Don't auto-create. Propose, confirm, then create with ` + "`pad role create`" +
### B5. Activate or rewrite library playbooks
Browse the playbook library. The canonical invokable playbooks are ` + "`plan`" + `, ` + "`decompose`" + `, and ` + "`ship`" + ` (software workspaces lean on all three; non-software workspaces might want only ` + "`plan`" + `). For each that's relevant:
Browse the playbook library. The canonical invokable playbooks are ` + "`plan`" + `, ` + "`decompose`" + `, and ` + "`ship`" + ` (software workspaces lean on all three; non-software workspaces might want only ` + "`plan`" + `).
Browse it the same way as conventions ` + "`pad library list --type playbooks`" + ` (CLI) or ` + "`pad_library`" + ` with ` + "`action: list, type: playbooks`" + ` (MCP). The list returns summaries by default; ` + "`pad library get \"<title>\"`" + ` / ` + "`pad_library`" + ` with ` + "`action: get, title: \"<title>\"`" + ` pulls the full body of one entry. For each that's relevant:
- Read the library body.
- Activate via ` + "`pad library activate \"<title>\"`" + ` (CLI) or ` + "`pad_library`" + ` with ` + "`action: activate, title: \"<title>\"`" + ` (MCP).
- If it needs project-specific tweaks (the seeded ` + "`ship`" + ` references ` + "`make install`" + ` change it if the project uses ` + "`npm run build`" + ` instead), activate AND THEN immediately edit the playbook body via ` + "`pad item update <PLAYB-ref> --stdin`" + ` with the rewritten content.
- If it doesn't fit at all, skip.
+88
View File
@@ -0,0 +1,88 @@
package mcp
// padLibraryTool exposes the global convention + playbook library to MCP
// callers. Read-mostly; the one mutation (activate) creates a workspace
// item from a library entry by title.
//
// PLAN-1560 / TASK-1563. The HTTP endpoints (TASK-1561) and CLI surface
// (TASK-1562) already exist; this file just makes them discoverable as
// a first-class MCP tool. Closes IDEA-1514 — pure-MCP agents (notably
// the /pad onboard playbook from PLAN-1496) can now walk the library
// without shelling out.
//
// Why Workspace=true even though list + get are global:
//
// - activate creates an item in the workspace's conventions/playbooks
// collection — it needs a workspace.
// - Mixing workspace-bound + workspace-free actions in one tool is the
// pad_meta precedent: declare Workspace=true so the catalog builder
// injects the workspace param into the schema, and let each action
// decide whether to use it. Bonus: activate gets automatic
// pad_set_workspace session-default resolution for free.
// - The alternative (Workspace=false + an explicit workspace ParamDef)
// would force callers to re-pass workspace on every activate call,
// skipping the session-default chain.
func init() {
appendToCatalog(padLibraryTool)
}
var padLibraryTool = ToolDef{
Name: "pad_library",
Description: padLibraryToolDescription,
Schema: ToolSchema{
Workspace: true,
Params: []ParamDef{
{
Name: "type",
Type: "string",
Description: "Filter list results by kind. Only used by action=list. Omit to list both.",
Enum: []string{"conventions", "playbooks"},
},
{
Name: "category",
Type: "string",
Description: "Server-side category filter (case-sensitive exact match against LibraryCategory.Name). Only used by action=list.",
},
{
Name: "title",
Type: "string",
Description: "Library entry title to address. Required for action=get and action=activate. Exact match; conventions are checked first, then playbooks.",
},
{
Name: "full",
Type: "bool",
Description: "When true, action=list returns full playbook bodies instead of the default summary. Conventions always carry full bodies (they're short). Use sparingly — agent context blows up fast. Default: false (summary mode).",
},
},
},
Actions: map[string]ActionFn{
"list": passThrough([]string{"library", "list"}),
"get": passThrough([]string{"library", "get"}),
"activate": passThrough([]string{"library", "activate"}),
},
}
const padLibraryToolDescription = `Convention + playbook library global catalog of pre-built entries that workspaces activate into their own conventions/playbooks collections.
Actions:
list Browse the library. By default playbooks come back as metadata + a short
summary (first non-heading paragraph, ~240 chars); conventions always carry
full bodies. Use action=get for the full body of one playbook.
Optional: type (conventions|playbooks), category, full.
get Full body of one entry by exact title. Conventions are checked first,
then playbooks same precedence activate uses, so a title resolves to the
same kind in both surfaces.
Required: title.
activate Create a workspace item from a library entry by title. Conventions land
in the conventions collection, playbooks land in the playbooks collection,
with all fields (trigger, scope, surfaces, enforcement, invocation_slug,
arguments) carried through from the library definition.
Required: workspace, title.
The library itself is workspace-agnostic list/get don't need workspace context. The
workspace param is still accepted (and honored by activate) so a session pinned via
pad_set_workspace doesn't need to re-pass it on every call.
Use pad_library when an agent needs to discover available conventions/playbooks (notably
during the /pad onboard interview) or activate a curated entry into a workspace.`
+34
View File
@@ -29,6 +29,8 @@ func TestReadOnlyCatalog_AllToolsRegistered(t *testing.T) {
"pad_search": false,
"pad_item": false,
"pad_playbook": false,
// pad_library — PLAN-1560 / TASK-1563. Closes IDEA-1514.
"pad_library": false,
}
for _, def := range Catalog {
if _, ok := want[def.Name]; ok {
@@ -125,6 +127,16 @@ func TestReadOnlyCatalog_ActionsMatchCmdhelp(t *testing.T) {
{"pad_playbook", "list"}: {"playbook", "list"},
{"pad_playbook", "get"}: {"playbook", "show"},
{"pad_playbook", "run"}: {"playbook", "run"},
// pad_library actions (PLAN-1560 / TASK-1563). All three
// passThrough to `pad library <subcommand>` — list composes both
// library endpoints with category + summary forwarding in the
// HTTPHandlerDispatcher path; get is a single GET to
// /library/entry; activate POSTs an item into conventions or
// playbooks based on title precedence.
{"pad_library", "list"}: {"library", "list"},
{"pad_library", "get"}: {"library", "get"},
{"pad_library", "activate"}: {"library", "activate"},
}
// Actions whose dispatch is too custom for the cmdPath bijection —
@@ -680,6 +692,28 @@ func liveCmdhelpDoc(t *testing.T) *cmdhelp.Document {
},
Flags: mkFlags("workspace"),
},
// pad_library surface (PLAN-1560 / TASK-1563). list / get /
// activate passThrough to `pad library <subcommand>`. The
// HTTPHandlerDispatcher path uses category + full inputs to
// build query strings; the ExecDispatcher path forwards them
// as CLI flags via BuildCLIArgs.
"library list": {
Summary: "list library",
Flags: map[string]cmdhelp.Flag{
"category": {Type: "string"},
"type": {Type: "string"},
"full": {Type: "bool"},
},
},
"library get": {
Summary: "get one library entry",
Args: mkArgs("title"),
},
"library activate": {
Summary: "activate library entry",
Args: mkArgs("title"),
Flags: mkFlags("workspace"),
},
},
}
}
+36 -6
View File
@@ -561,10 +561,13 @@ func userActorLabel(user *models.User) string {
// - --type playbooks → returns the playbook library (plib).
// - (no --type) → returns {conventions: lib, playbooks: plib}.
//
// `--category` is intentionally not applied here — the CLI also
// doesn't filter the JSON output by category (it's purely a
// human-readable rendering filter). Agents that want category
// filtering can apply it client-side over the returned categories[].
// Inputs honored (PLAN-1560 / TASK-1561+TASK-1563):
// - type: restricts to conventions or playbooks (above).
// - category: server-side category filter; case-sensitive exact match.
// Forwarded to BOTH endpoints when set.
// - full: when true, playbooks come back with full bodies. Default
// (full=false) passes ?summary=true so MCP agents browse
// the catalog without blowing their context budget.
//
// The endpoints are global (no workspace), so we don't read
// `workspace` from input. Both endpoints require an authenticated
@@ -586,18 +589,45 @@ func (d *HTTPHandlerDispatcher) dispatchLibraryList(
"Pass `type=conventions`, `type=playbooks`, or omit for both."), nil
}
category, _ := input["category"].(string)
full, _ := input["full"].(bool)
// Build query strings per endpoint. Convention endpoint takes only
// category; playbook endpoint takes both category and summary.
conventionQuery := url.Values{}
playbookQuery := url.Values{}
if category != "" {
conventionQuery.Set("category", category)
playbookQuery.Set("category", category)
}
if !full {
// Default: summary mode for playbooks. MCP callers want compact
// payloads; CLI default already does this too. Opt back in via
// full=true.
playbookQuery.Set("summary", "true")
}
conventionPath := "/api/v1/convention-library"
if encoded := conventionQuery.Encode(); encoded != "" {
conventionPath += "?" + encoded
}
playbookPath := "/api/v1/playbook-library"
if encoded := playbookQuery.Encode(); encoded != "" {
playbookPath += "?" + encoded
}
var conventions any
var playbooks any
if wantConventions {
v, errRes := d.fetchLibraryEndpoint(ctx, user, cmdKey, "/api/v1/convention-library")
v, errRes := d.fetchLibraryEndpoint(ctx, user, cmdKey, conventionPath)
if errRes != nil {
return errRes, nil
}
conventions = v
}
if wantPlaybooks {
v, errRes := d.fetchLibraryEndpoint(ctx, user, cmdKey, "/api/v1/playbook-library")
v, errRes := d.fetchLibraryEndpoint(ctx, user, cmdKey, playbookPath)
if errRes != nil {
return errRes, nil
}
@@ -663,6 +663,105 @@ func TestDispatch_LibraryList_RejectsUnknownType(t *testing.T) {
}
}
// TestDispatch_LibraryList_ForwardsCategoryAndSummary pins TASK-1563's
// extension to dispatchLibraryList: `category` flows to BOTH endpoints
// as a query param, and the default `summary=true` flag passes to the
// playbook endpoint unless input.full=true.
func TestDispatch_LibraryList_ForwardsCategoryAndSummary(t *testing.T) {
cases := []struct {
name string
input map[string]any
wantConvQuery string
wantPlaybookQuery string
}{
{
name: "defaults — summary=true, no category",
input: map[string]any{},
wantConvQuery: "",
wantPlaybookQuery: "summary=true",
},
{
name: "category set — forwards to both",
input: map[string]any{"category": "git"},
wantConvQuery: "category=git",
wantPlaybookQuery: "category=git&summary=true",
},
{
name: "full=true — suppresses summary",
input: map[string]any{"full": true},
wantConvQuery: "",
wantPlaybookQuery: "",
},
{
name: "category + full — category forwarded, summary suppressed",
input: map[string]any{"category": "agent-workflows", "full": true},
wantConvQuery: "category=agent-workflows",
wantPlaybookQuery: "category=agent-workflows",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mux := http.NewServeMux()
var convQuery, pbQuery string
mux.HandleFunc("/api/v1/convention-library", func(w http.ResponseWriter, r *http.Request) {
convQuery = r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"categories":[]}`))
})
mux.HandleFunc("/api/v1/playbook-library", func(w http.ResponseWriter, r *http.Request) {
pbQuery = r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"categories":[]}`))
})
d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "u"})}
res, err := d.Dispatch(
WithDispatchInput(context.Background(), tc.input),
[]string{"library", "list"}, nil,
)
if err != nil || res.IsError {
t.Fatalf("Dispatch err=%v IsError=%v: %#v", err, res != nil && res.IsError, res)
}
if convQuery != tc.wantConvQuery {
t.Errorf("convention query = %q, want %q", convQuery, tc.wantConvQuery)
}
if pbQuery != tc.wantPlaybookQuery {
t.Errorf("playbook query = %q, want %q", pbQuery, tc.wantPlaybookQuery)
}
})
}
}
// TestDispatch_LibraryGet_RoutesToEntryEndpoint confirms the routeTable
// entry resolves: the dispatcher calls /library/entry?title=X and
// returns the response payload as the structured content.
func TestDispatch_LibraryGet_RoutesToEntryEndpoint(t *testing.T) {
mux := http.NewServeMux()
var seenQuery string
mux.HandleFunc("/api/v1/library/entry", func(w http.ResponseWriter, r *http.Request) {
seenQuery = r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"type":"convention","convention":{"title":"Commit after task completion","content":"..."}}`))
})
d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "u"})}
res, err := d.Dispatch(
WithDispatchInput(context.Background(), map[string]any{"title": "Commit after task completion"}),
[]string{"library", "get"}, nil,
)
if err != nil || res.IsError {
t.Fatalf("Dispatch err=%v IsError=%v: %#v", err, res != nil && res.IsError, res)
}
if !strings.Contains(seenQuery, "title=") {
t.Errorf("expected ?title=... in query, got %q", seenQuery)
}
payload, ok := res.StructuredContent.(map[string]any)
if !ok {
t.Fatalf("expected map structured content; got %#v", res.StructuredContent)
}
if payload["type"] != "convention" {
t.Errorf("expected type=convention, got %v", payload["type"])
}
}
// --- item bulk-update ---
func TestBulkUpdateRefs_AcceptsCommonShapes(t *testing.T) {
+12
View File
@@ -249,6 +249,18 @@ func init() {
pathTemplate: "/api/v1/workspaces/{workspace}/playbooks/{ref}",
}.toRouteMapper(),
"playbook run": mapPlaybookRun,
// Library single-entry lookup (PLAN-1560 / TASK-1561 endpoint,
// TASK-1563 MCP wiring). Workspace-free — the library is global.
// list/activate stay as explicit cases in dispatch_http.go (they
// compose multiple endpoints / mutate); get is a clean GET so a
// routeSpec covers it.
"library get": routeSpec{
method: http.MethodGet,
pathTemplate: "/api/v1/library/entry",
queryParams: map[string]string{"title": "title"},
}.toRouteMapper(),
"collection list": routeSpec{
method: http.MethodGet,
pathTemplate: "/api/v1/workspaces/{workspace}/collections",
+9 -2
View File
@@ -65,7 +65,14 @@ const CmdhelpVersion = "0.1"
// context. Purely additive; older clients that ignore unknown
// keys keep working.
// - pad://workspace/{ws}/bootstrap resource added.
// - "0.4" — current. PLAN-1410: comprehensive bootstrap-payload
// - "0.5" — current. PLAN-1560 / TASK-1563: adds `pad_library` to the
// catalog as the ninth resource × action tool. Three actions —
// list / get / activate — surface the global convention + playbook
// library (previously CLI-only) to MCP callers. Pure addition; no
// existing tool/action/param/bootstrap shapes changed. Backwards-
// compatible for any v0.4 consumer that doesn't enumerate the new
// tool.
// - "0.4" — PLAN-1410: comprehensive bootstrap-payload
// trim, cutting ~40% of bytes off the AgentBootstrap response.
// Same tool catalog (still eight resource × action tools +
// pad_set_workspace); the shape changes are entirely inside the
@@ -107,7 +114,7 @@ const CmdhelpVersion = "0.1"
// - result.capabilities.experimental.padToolSurface.version (handshake).
// - pad://_meta/version resource (queryable JSON document).
// - pad_meta.action: tool-surface (full catalog introspection).
const ToolSurfaceVersion = "0.4"
const ToolSurfaceVersion = "0.5"
// MetaVersionURI is the canonical URI of the queryable version document.
// Lives outside the pad://workspace/{ws}/... namespace because it's a