Files
pad/internal/mcp/dispatch_http_project.go
T
xarmian ae62c097ca refactor(mcp): consolidate project next/standup/changelog onto REST endpoints (TASK-1916) (#802)
* refactor(mcp): consolidate project next/standup/changelog onto REST endpoints (TASK-1916)

dispatchProjectNext/Standup/Changelog were a second server-side copy of
the next/standup/changelog reshaping contract, written before TASK-1894
shipped dedicated REST endpoints for the same data. Replace the ~200
lines of duplicate reshaping with thin proxies that validate workspace
(preserving the pad_set_workspace hint) and forward to
GET /next|/standup|/changelog, relying on packageHTTPResponse's existing
array-wrap (BUG-985) and the REST handlers' own days-default and
per-status best-effort semantics rather than replicating them.

dispatch_http_slice4.go is deleted; its unrelated dispatchLibraryActivate
moves to dispatch_http_library.go now that the file's other three
methods are gone. KEEP IN SYNC comments across
handlers_project_intel.go/server.go/tests collapse from "three
reproductions" to "CLI + REST, MCP proxies to REST."

* fix(server): pass nav-lenient visibleIDs to changelog's parent enrichment (codex R1 P1, TASK-1916)

handleGetProjectChangelog passed guestResourceFilter's narrowed collIDs
into enrichItemsWithParent instead of the nav-lenient visibleCollectionIDs
set handleListItems uses for the same enrichment call. For a guest whose
granted item's parent lives in an item-grant-only collection (nav-visible
but excluded from the narrowed full-access set), this silently dropped
the parent link fields, causing itemMatchesParentFilter to exclude the
item from ?parent= results even though the guest can otherwise see it.

The root cause predates TASK-1916 (introduced alongside the REST endpoint
in TASK-1894), but this consolidation imports it into MCP wire behavior
via dispatchProjectChangelog's proxy, so it's in scope to fix here.

projectIntelVisibility now returns the unnarrowed visibleCollectionIDs
result (navVisibleIDs) alongside the existing (collIDs, itemIDs) pair;
handleGetProjectChangelog uses navVisibleIDs for enrichItemsWithParent
while keeping collIDs for the list query, mirroring handleListItems'
pattern exactly. handleGetProjectStandup and handleGetProjectNext have no
parallel enrichItemsWithParent call (verified by reading both, and
buildDashboardResponse) so neither needed the same treatment.

Added TestProjectChangelogEndpoint_GuestParentFilter_ItemGrantOnlyCollection,
confirmed to fail against the pre-fix code and pass against the fix.
2026-07-03 22:29:40 -04:00

778 lines
28 KiB
Go

package mcp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"sort"
"strings"
"time"
"github.com/mark3labs/mcp-go/mcp"
"github.com/PerpetualSoftware/pad/internal/models"
)
// dispatchProjectReady reproduces `pad project ready --format json` —
// the CLI returns `{count, results}` extracted from the dashboard's
// SuggestedNext slice, NOT the full dashboard payload. (Compare with
// `pad project next` which returns the raw dashboard JSON; both surface
// the same suggestions but with different framing.)
//
// Aliasing to /dashboard would be a behavioural divergence: the
// agent would see an unexpected wrapper shape and have to know to dig
// into `suggested_next`. Mirroring the CLI's `{count, results}` shape
// keeps the MCP transport equivalent to ExecDispatcher.
func (d *HTTPHandlerDispatcher) dispatchProjectReady(
ctx context.Context,
input map[string]any,
user *models.User,
) (*mcp.CallToolResult, error) {
const cmdKey = "project ready"
dash, errRes := d.fetchDashboardJSON(ctx, input, user, cmdKey)
if errRes != nil {
return errRes, nil
}
suggestions := dashboardArrayField(dash, "suggested_next")
return packageStructuredResponse(cmdKey, map[string]any{
"count": len(suggestions),
"results": suggestions,
})
}
// dispatchProjectStale reproduces `pad project stale --format json` —
// CLI filters the dashboard's Attention slice to "interesting" types
// (stalled / blocked / overdue / orphaned_task) before returning
// `{count, results}`. Sorting matches cmd/pad/query.go's
// filterAgentAttention: type, ItemRef, ItemTitle.
//
// Operates on the raw map[string]any decoded from the dashboard JSON
// so any field server.DashboardAttention adds in future versions
// (collection, plus anything not yet wired) flows through unchanged.
// Codex review on PR #348 round 1 caught the previous typed-struct
// approach dropping `collection` from the response.
func (d *HTTPHandlerDispatcher) dispatchProjectStale(
ctx context.Context,
input map[string]any,
user *models.User,
) (*mcp.CallToolResult, error) {
const cmdKey = "project stale"
dash, errRes := d.fetchDashboardJSON(ctx, input, user, cmdKey)
if errRes != nil {
return errRes, nil
}
attention := filterAgentAttention(dashboardArrayField(dash, "attention"))
return packageStructuredResponse(cmdKey, map[string]any{
"count": len(attention),
"results": attention,
})
}
// dispatchProjectNext proxies `project next` to GET
// /workspaces/{ws}/next — handleGetProjectNext
// (internal/server/handlers_project_intel.go), the browser/REST
// surface PLAN-1888 / TASK-1894 added. Before TASK-1916 this method
// fetched the dashboard itself and sliced out suggested_next (there
// was no REST endpoint to route to yet — see BUG-987 bug 6: without
// that slicing, `project next` was indistinguishable from `project
// dashboard`); now the REST endpoint already returns exactly that
// slice, so this is a thin validate-then-forward.
//
// Kept as a hand-written method rather than a routeTable entry so the
// missing-workspace error keeps the pad_set_workspace hint every other
// workspace-required dispatcher uses (a plain routeSpec's missing
// {workspace} placeholder produces a more generic "check your input
// shape" hint instead — see expandPath in dispatch_http_routes.go).
//
// No re-marshaling needed for the BUG-985 array-wrap: executeRequest's
// packageHTTPResponse already wraps top-level JSON arrays as
// {items: [...]} for every proxied GET, and /next returns a bare
// array.
func (d *HTTPHandlerDispatcher) dispatchProjectNext(
ctx context.Context,
input map[string]any,
user *models.User,
) (*mcp.CallToolResult, error) {
const cmdKey = "project next"
workspace, _ := input["workspace"].(string)
if workspace == "" {
return validationFailedResult(cmdKey, "workspace is required",
"Pass `workspace=<slug>` or set a session default via pad_set_workspace."), nil
}
path := "/api/v1/workspaces/" + url.PathEscape(workspace) + "/next"
return d.executeRequest(ctx, cmdKey, user, http.MethodGet, path, nil)
}
// dispatchProjectStandup proxies `project standup` to GET
// /workspaces/{ws}/standup?days=N — handleGetProjectStandup
// (internal/server/handlers_project_intel.go). Before TASK-1916 this
// method reproduced the CLI's standupCmd JSON branch itself (dashboard
// fetch + per-terminal-status ListItems loop + in-progress fetch);
// that reshaping, its days=1 default, and its per-status best-effort
// error handling (a failed status is skipped, not fatal) now all live
// server-side in handleGetProjectStandup / listTerminalItemsSince,
// so this method only validates workspace (same pad_set_workspace
// hint as dispatchProjectNext above, same routeTable-vs-custom-method
// reasoning) and forwards `days` as-is via the shared buildQuery
// helper (dispatch_http_routes.go) — parseDaysParam server-side
// applies the identical n<=0-falls-back-to-default gate the
// in-dispatcher `numericInput(...) && n > 0` check used to.
func (d *HTTPHandlerDispatcher) dispatchProjectStandup(
ctx context.Context,
input map[string]any,
user *models.User,
) (*mcp.CallToolResult, error) {
const cmdKey = "project standup"
workspace, _ := input["workspace"].(string)
if workspace == "" {
return validationFailedResult(cmdKey, "workspace is required",
"Pass `workspace=<slug>` or set a session default via pad_set_workspace."), nil
}
path := "/api/v1/workspaces/" + url.PathEscape(workspace) + "/standup"
if q := buildQuery(input, map[string]string{"days": "days"}); q != "" {
path += "?" + q
}
return d.executeRequest(ctx, cmdKey, user, http.MethodGet, path, nil)
}
// dispatchProjectChangelog proxies `project changelog` to GET
// /workspaces/{ws}/changelog?days=N&since=YYYY-MM-DD&parent=REF —
// handleGetProjectChangelog (internal/server/handlers_project_intel.go).
// Same consolidation as dispatchProjectStandup above: the per-status
// listing loop, days=7 default, since-overrides-days precedence,
// parent filtering, and collection grouping all now live server-side.
//
// One observable behavior change from consolidation: a malformed
// `since` used to get a bespoke dispatcher-side hint ("Pass
// since=YYYY-MM-DD..."); now it flows through the REST endpoint's 400
// and the generic HTTP-error classifier (classifyHTTPStatusKind) like
// every other proxied validation error in this codebase — same
// ErrValidationFailed code, but the hint text becomes the generic
// "Backend: invalid 'since' date...Adjust the input shape and retry."
// rather than the old bespoke wording. Accepted as part of TASK-1916's
// consolidation (keeping the bespoke hint here would mean re-parsing
// `since` twice and defeats the point of proxying).
func (d *HTTPHandlerDispatcher) dispatchProjectChangelog(
ctx context.Context,
input map[string]any,
user *models.User,
) (*mcp.CallToolResult, error) {
const cmdKey = "project changelog"
workspace, _ := input["workspace"].(string)
if workspace == "" {
return validationFailedResult(cmdKey, "workspace is required",
"Pass `workspace=<slug>` or set a session default via pad_set_workspace."), nil
}
path := "/api/v1/workspaces/" + url.PathEscape(workspace) + "/changelog"
if q := buildQuery(input, map[string]string{"days": "days", "since": "since", "parent": "parent"}); q != "" {
path += "?" + q
}
return d.executeRequest(ctx, cmdKey, user, http.MethodGet, path, nil)
}
// fetchDashboardJSON hits the workspace dashboard endpoint and decodes
// the response into a generic map[string]any so the dispatcher
// preserves every field the server emits — no maintenance burden when
// new fields land on DashboardAttention / DashboardSuggestion.
//
// Returns the raw object so callers can pull specific fields
// (suggested_next, attention) via dashboardArrayField without the
// typed-struct round-trip.
func (d *HTTPHandlerDispatcher) fetchDashboardJSON(
ctx context.Context,
input map[string]any,
user *models.User,
cmdKey string,
) (map[string]any, *mcp.CallToolResult) {
workspace, _ := input["workspace"].(string)
if workspace == "" {
return nil, validationFailedResult(cmdKey, "workspace is required",
"Pass `workspace=<slug>` or set a session default via pad_set_workspace.")
}
path := "/api/v1/workspaces/" + url.PathEscape(workspace) + "/dashboard"
req, err := d.buildAuthedRequest(ctx, http.MethodGet, path, nil, user)
if err != nil {
return nil, dispatcherErrorResult(cmdKey, "build dashboard request", err)
}
rec := httptest.NewRecorder()
d.Handler.ServeHTTP(rec, req)
if rec.Code >= 400 {
return nil, upstreamHTTPErrorResult(ctx, cmdKey, "fetch dashboard", path,
rec.Code, rec.Body.Bytes(), d.Lister, ResourceWorkspace, workspace)
}
var dash map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &dash); err != nil {
return nil, dispatcherErrorResult(cmdKey, "parse dashboard", err)
}
return dash, nil
}
// dashboardArrayField pulls a named array out of a decoded dashboard
// payload, returning a typed []map[string]any so the callers can
// filter / sort by string fields without the json.Number / interface{}
// dance per element. Missing/empty/non-array values normalize to an
// empty slice so the {count, results} responses always emit a usable
// shape.
func dashboardArrayField(dash map[string]any, key string) []map[string]any {
raw, ok := dash[key].([]any)
if !ok {
return []map[string]any{}
}
out := make([]map[string]any, 0, len(raw))
for _, e := range raw {
if m, ok := e.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// filterAgentAttention mirrors cmd/pad/query.go's helper of the same
// name — keeps only the attention types agents care about (stalled,
// blocked, overdue, orphaned_task) and sorts deterministically by
// (type, item_ref, item_title). Same stable ordering as the CLI so
// `--format json` outputs match between transports.
//
// Operates on map[string]any (not a typed struct) so attention
// entries pass through to the response with EVERY field the server
// emitted, not just the ones we knew to declare. Codex review on PR
// #348 caught the previous typed approach dropping `collection`.
func filterAgentAttention(attention []map[string]any) []map[string]any {
interesting := map[string]bool{
"stalled": true,
"blocked": true,
"overdue": true,
"orphaned_task": true,
}
results := make([]map[string]any, 0, len(attention))
for _, item := range attention {
typ, _ := item["type"].(string)
if interesting[typ] {
results = append(results, item)
}
}
sort.SliceStable(results, func(i, j int) bool {
ti, _ := results[i]["type"].(string)
tj, _ := results[j]["type"].(string)
if ti != tj {
return ti < tj
}
ri, _ := results[i]["item_ref"].(string)
rj, _ := results[j]["item_ref"].(string)
if ri != rj {
return ri < rj
}
titI, _ := results[i]["item_title"].(string)
titJ, _ := results[j]["item_title"].(string)
return titI < titJ
})
return results
}
// --- item bulk-update ---
// dispatchItemBulkUpdate iterates the input's `ref` array and applies
// --status / --priority via the same read-modify-write semantics the
// item.update path uses (so existing fields survive). Mirrors the
// CLI's bulkUpdateCmd: at-least-one-of-status-or-priority gating, per-
// item GET → field merge → PATCH, and a per-item success/error report.
//
// The cmdhelp surface marks `ref` as required AND repeatable — agents
// pass it as either []any (typical JSON array) or []string. Anything
// else is rejected so the dispatcher doesn't silently iterate over
// nothing.
//
// Per-item failures don't abort the bulk operation; they get
// individually reported in the response so an agent can inspect what
// succeeded vs. failed without having to retry the whole batch.
func (d *HTTPHandlerDispatcher) dispatchItemBulkUpdate(
ctx context.Context,
input map[string]any,
user *models.User,
) (*mcp.CallToolResult, error) {
const cmdKey = "item bulk-update"
workspace, _ := input["workspace"].(string)
if workspace == "" {
return validationFailedResult(cmdKey, "workspace is required",
"Pass `workspace=<slug>` or set a session default via pad_set_workspace."), nil
}
refs, err := bulkUpdateRefs(input["ref"])
if err != nil {
return validationFailedResult(cmdKey, err.Error(),
"Pass `ref` as a string or array of TASK-N / BUG-N / etc. refs."), nil
}
if len(refs) == 0 {
return validationFailedResult(cmdKey, "at least one ref is required",
"Pass at least one item ref (e.g. ref=[\"TASK-7\",\"TASK-8\"])."), nil
}
status, _ := input["status"].(string)
priority, _ := input["priority"].(string)
if status == "" && priority == "" {
return validationFailedResult(cmdKey, "at least one of status or priority is required",
"Pass `status=<value>` and/or `priority=<value>` to specify what to update."), nil
}
type bulkResult struct {
Ref string `json:"ref"`
Updated bool `json:"updated"`
Error *ErrorPayload `json:"error,omitempty"`
}
results := make([]bulkResult, 0, len(refs))
successes := 0
// rowError builds a per-row ErrorPayload. Reuses the same shape
// the top-level error envelope uses — agents see consistent
// {code, message, hint} per failed row instead of bare strings
// (BUG-1077 / Bug 15: bulk-update was the partially-fixed case
// that surfaced this requirement).
rowError := func(code ErrorCode, msg, hint string) *ErrorPayload {
return &ErrorPayload{Code: code, Message: msg, Hint: hint}
}
rowDispatcherError := func(op string, err error) *ErrorPayload {
return rowError(ErrServerError, fmt.Sprintf("%s failed", op),
fmt.Sprintf("Internal: %s — %s", op, err.Error()))
}
rowUpstreamError := func(op, route string, status int, body []byte, ref string) *ErrorPayload {
res := classifyHTTPStatusKind(ctx, cmdKey, route, status, body, d.Lister, ResourceItem, ref)
env := envelopeFrom(res)
if op != "" && env.Error.Message != "" {
env.Error.Message = fmt.Sprintf("%s (%s)", env.Error.Message, op)
}
return &env.Error
}
for _, ref := range refs {
// Per-item RMW: GET, merge fields, PATCH. Same shape
// dispatchItemUpdate uses, but inlined here so a per-item
// failure produces a {ref, error} entry instead of aborting.
itemPath := "/api/v1/workspaces/" + url.PathEscape(workspace) +
"/items/" + url.PathEscape(ref)
getReq, err := d.buildAuthedRequest(ctx, http.MethodGet, itemPath, nil, user)
if err != nil {
results = append(results, bulkResult{Ref: ref, Error: rowDispatcherError("build request", err)})
continue
}
getRec := httptest.NewRecorder()
d.Handler.ServeHTTP(getRec, getReq)
if getRec.Code >= 400 {
results = append(results, bulkResult{
Ref: ref,
Error: rowUpstreamError("read item", itemPath, getRec.Code, getRec.Body.Bytes(), ref),
})
continue
}
var existing struct {
Fields string `json:"fields"`
}
if err := json.Unmarshal(getRec.Body.Bytes(), &existing); err != nil {
results = append(results, bulkResult{Ref: ref, Error: rowDispatcherError("parse item", err)})
continue
}
merged := map[string]any{}
if existing.Fields != "" && existing.Fields != "{}" {
if err := json.Unmarshal([]byte(existing.Fields), &merged); err != nil {
results = append(results, bulkResult{Ref: ref, Error: rowDispatcherError("parse existing fields", err)})
continue
}
}
if status != "" {
merged["status"] = status
}
if priority != "" {
merged["priority"] = priority
}
fieldsJSON, err := json.Marshal(merged)
if err != nil {
results = append(results, bulkResult{Ref: ref, Error: rowDispatcherError("encode fields", err)})
continue
}
fieldsStr := string(fieldsJSON)
patchPayload := map[string]any{"fields": fieldsStr}
// IDEA-1494: forward the open-children guard override per-row.
// Same flag shape as `pad item bulk-update --force` so the
// override travels through both transports identically.
if b, ok := input["force"].(bool); ok && b {
patchPayload["force"] = true
}
patchBody, err := json.Marshal(patchPayload)
if err != nil {
results = append(results, bulkResult{Ref: ref, Error: rowDispatcherError("encode body", err)})
continue
}
patchReq, err := d.buildAuthedRequest(ctx, http.MethodPatch, itemPath, patchBody, user)
if err != nil {
results = append(results, bulkResult{Ref: ref, Error: rowDispatcherError("build PATCH", err)})
continue
}
patchRec := httptest.NewRecorder()
d.Handler.ServeHTTP(patchRec, patchReq)
if patchRec.Code >= 400 {
results = append(results, bulkResult{
Ref: ref,
Error: rowUpstreamError("update item", itemPath, patchRec.Code, patchRec.Body.Bytes(), ref),
})
continue
}
results = append(results, bulkResult{Ref: ref, Updated: true})
successes++
}
payload := map[string]any{
"updated": successes,
"total": len(refs),
"results": results,
}
return packageStructuredResponse(cmdKey, payload)
}
// bulkUpdateRefs canonicalizes the `ref` input — accepts repeatable
// shapes the cmdhelp registry generates (string for a single value,
// []any from JSON arrays, []string from typed callers) into a clean
// []string. Empty / non-string entries are rejected so we don't
// silently skip elements an agent expected to be processed.
func bulkUpdateRefs(raw any) ([]string, error) {
switch v := raw.(type) {
case nil:
return nil, nil
case string:
if v == "" {
return nil, nil
}
return []string{v}, nil
case []string:
out := make([]string, 0, len(v))
for i, s := range v {
if s == "" {
return nil, fmt.Errorf("ref[%d] is empty", i)
}
out = append(out, s)
}
return out, nil
case []any:
out := make([]string, 0, len(v))
for i, e := range v {
s, ok := e.(string)
if !ok {
return nil, fmt.Errorf("ref[%d] must be a string, got %T", i, e)
}
if s == "" {
return nil, fmt.Errorf("ref[%d] is empty", i)
}
out = append(out, s)
}
return out, nil
default:
return nil, fmt.Errorf("ref must be a string or array of strings, got %T", raw)
}
}
// --- item note + decide (RMW append) ---
// dispatchItemNote handles `pad item note <ref> <summary>
// [--details ...]` — appends an implementation-note entry to the
// item's structured-fields blob, then PATCHes.
//
// Same RMW shape as dispatchItemUpdate but using
// models.AppendImplementationNote so the entry gets the right shape
// + ID + timestamp the CLI applies.
//
// Emits the updated item (the PATCH response) like every other
// dispatcher — agents see the same shape they'd get from a follow-up
// `item show`.
func (d *HTTPHandlerDispatcher) dispatchItemNote(
ctx context.Context,
input map[string]any,
user *models.User,
) (*mcp.CallToolResult, error) {
const cmdKey = "item note"
workspace, _ := input["workspace"].(string)
ref, _ := input["ref"].(string)
summary, _ := input["summary"].(string)
if workspace == "" {
return validationFailedResult(cmdKey, "workspace is required",
"Pass `workspace=<slug>` or set a session default via pad_set_workspace."), nil
}
if ref == "" {
return validationFailedResult(cmdKey, "ref is required",
"Pass `ref=<TASK-N>` (or whichever item ref the note targets)."), nil
}
if summary == "" {
return validationFailedResult(cmdKey, "summary is required",
"Pass `summary=<short text>` describing the note."), nil
}
details, _ := input["details"].(string)
details = strings.TrimSpace(details)
itemPath := "/api/v1/workspaces/" + url.PathEscape(workspace) +
"/items/" + url.PathEscape(ref)
currentFields, errRes := d.prefetchItemFields(ctx, user, cmdKey, itemPath, ref)
if errRes != nil {
return errRes, nil
}
updated, err := models.AppendImplementationNote(currentFields, models.ItemImplementationNote{
ID: newStructuredEntryID("note"),
Summary: strings.TrimSpace(summary),
Details: details,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
CreatedBy: userActorLabel(user),
})
if err != nil {
return dispatcherErrorResult(cmdKey, "append note", err), nil
}
body, err := json.Marshal(map[string]any{"fields": updated})
if err != nil {
return dispatcherErrorResult(cmdKey, "encode body", err), nil
}
return d.executeRequest(ctx, cmdKey, user, http.MethodPatch, itemPath, body)
}
// dispatchItemDecide is the decision-log analogue of
// dispatchItemNote — same RMW shape, just using
// AppendDecisionLogEntry on a different fields slot.
func (d *HTTPHandlerDispatcher) dispatchItemDecide(
ctx context.Context,
input map[string]any,
user *models.User,
) (*mcp.CallToolResult, error) {
const cmdKey = "item decide"
workspace, _ := input["workspace"].(string)
ref, _ := input["ref"].(string)
decision, _ := input["decision"].(string)
if workspace == "" {
return validationFailedResult(cmdKey, "workspace is required",
"Pass `workspace=<slug>` or set a session default via pad_set_workspace."), nil
}
if ref == "" {
return validationFailedResult(cmdKey, "ref is required",
"Pass `ref=<TASK-N>` (or whichever item ref the decision targets)."), nil
}
if decision == "" {
return validationFailedResult(cmdKey, "decision is required",
"Pass `decision=<short text>` describing the decision."), nil
}
rationale, _ := input["rationale"].(string)
rationale = strings.TrimSpace(rationale)
itemPath := "/api/v1/workspaces/" + url.PathEscape(workspace) +
"/items/" + url.PathEscape(ref)
currentFields, errRes := d.prefetchItemFields(ctx, user, cmdKey, itemPath, ref)
if errRes != nil {
return errRes, nil
}
updated, err := models.AppendDecisionLogEntry(currentFields, models.ItemDecisionLogEntry{
ID: newStructuredEntryID("decision"),
Decision: strings.TrimSpace(decision),
Rationale: rationale,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
CreatedBy: userActorLabel(user),
})
if err != nil {
return dispatcherErrorResult(cmdKey, "append decision", err), nil
}
body, err := json.Marshal(map[string]any{"fields": updated})
if err != nil {
return dispatcherErrorResult(cmdKey, "encode body", err), nil
}
return d.executeRequest(ctx, cmdKey, user, http.MethodPatch, itemPath, body)
}
// prefetchItemFields GETs the item at itemPath and returns its
// `fields` JSON string. Surfaces 404s and parse errors as
// IsError-flagged tool results so the dispatcher's caller can return
// them directly without further wrapping.
//
// Used by note/decide which append into the existing fields blob —
// they need the current value so AppendImplementationNote /
// AppendDecisionLogEntry can preserve other entries.
//
// ref is the item ref the caller was looking up; threaded through to
// the error envelope so agents see e.g. "Item TASK-7 not found"
// rather than a bare 404 (TASK-1078 / TASK-1079).
func (d *HTTPHandlerDispatcher) prefetchItemFields(
ctx context.Context,
user *models.User,
cmdKey, itemPath, ref string,
) (string, *mcp.CallToolResult) {
req, err := d.buildAuthedRequest(ctx, http.MethodGet, itemPath, nil, user)
if err != nil {
return "", dispatcherErrorResult(cmdKey, "build prefetch", err)
}
rec := httptest.NewRecorder()
d.Handler.ServeHTTP(rec, req)
if rec.Code >= 400 {
return "", upstreamHTTPErrorResult(ctx, cmdKey, "prefetch item", itemPath,
rec.Code, rec.Body.Bytes(), d.Lister, ResourceItem, ref)
}
var existing struct {
Fields string `json:"fields"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &existing); err != nil {
return "", dispatcherErrorResult(cmdKey, "parse current item", err)
}
return existing.Fields, nil
}
// newStructuredEntryID mirrors the CLI's helper for note/decision
// IDs (cmd/pad/notes.go). The actual collision-avoidance is handled
// by combining the prefix + a unix-nano timestamp — same shape so
// CLI-created and MCP-created entries are indistinguishable in
// downstream consumers.
func newStructuredEntryID(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, time.Now().UTC().UnixNano())
}
// userActorLabel produces a stable string label for the actor that
// created a structured entry. Mirrors the CLI's "user" label for
// CLI-driven entries; for MCP we use the requesting user's name (or
// email fallback) so audit-log review can tell who appended what
// when multiple users share the same MCP server.
func userActorLabel(user *models.User) string {
if user == nil {
return "user"
}
if user.Name != "" {
return user.Name
}
if user.Email != "" {
return user.Email
}
return "user"
}
// dispatchLibraryList composes the /convention-library and
// /playbook-library endpoints to mirror `pad library list --format
// json`. The CLI's JSON output shape varies on --type:
//
// - --type conventions → returns the convention library (lib).
// - --type playbooks → returns the playbook library (plib).
// - (no --type) → returns {conventions: lib, playbooks: plib}.
//
// 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
// user; the route table-level Apply hook handles that uniformly.
func (d *HTTPHandlerDispatcher) dispatchLibraryList(
ctx context.Context,
input map[string]any,
user *models.User,
) (*mcp.CallToolResult, error) {
const cmdKey = "library list"
typ, _ := input["type"].(string)
typ = strings.ToLower(strings.TrimSpace(typ))
wantConventions := typ == "" || typ == "conventions"
wantPlaybooks := typ == "" || typ == "playbooks"
if !wantConventions && !wantPlaybooks {
return validationFailedResult(cmdKey,
fmt.Sprintf("unknown --type %q", typ),
"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, conventionPath)
if errRes != nil {
return errRes, nil
}
conventions = v
}
if wantPlaybooks {
v, errRes := d.fetchLibraryEndpoint(ctx, user, cmdKey, playbookPath)
if errRes != nil {
return errRes, nil
}
playbooks = v
}
// Single-type mode returns the library payload directly (matches
// the CLI). Both-types mode wraps in {conventions, playbooks}.
switch {
case wantConventions && wantPlaybooks:
return packageStructuredResponse(cmdKey, map[string]any{
"conventions": conventions,
"playbooks": playbooks,
})
case wantConventions:
return packageStructuredResponse(cmdKey, conventions)
default:
return packageStructuredResponse(cmdKey, playbooks)
}
}
// fetchLibraryEndpoint GETs one of the library endpoints and decodes
// the JSON body into a generic any so the caller can stuff it into
// the composed response without losing the wire shape.
func (d *HTTPHandlerDispatcher) fetchLibraryEndpoint(
ctx context.Context,
user *models.User,
cmdKey, path string,
) (any, *mcp.CallToolResult) {
req, err := d.buildAuthedRequest(ctx, http.MethodGet, path, nil, user)
if err != nil {
return nil, dispatcherErrorResult(cmdKey, "build "+path, err)
}
rec := httptest.NewRecorder()
d.Handler.ServeHTTP(rec, req)
if rec.Code >= 400 {
return nil, upstreamHTTPErrorResult(ctx, cmdKey, "fetch "+path, path,
rec.Code, rec.Body.Bytes(), d.Lister, ResourceListing, "")
}
var decoded any
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
return nil, dispatcherErrorResult(cmdKey, "parse "+path, err)
}
return decoded, nil
}