Files
pad/internal/mcp/dispatch_http_project.go
T
xarmian 493498c977 feat(items): idempotency keys for comments, notes and decisions; idempotency_target_gone is structured (TASK-3585) (#2068)
* fix(mcp): idempotency_target_gone is a structured code on both transports (TASK-3585)

A replayed create whose item has since been deleted answers 410
idempotency_target_gone. Remote MCP had no 410 arm, and the CLI wrote no
stdio marker for it, so it reached agents as a generic error. It is now
allow-listed, mapped on the remote 410 arm, and marked on stdio, with a
hint shared between internal/cli and internal/mcp.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* feat(items): idempotency keys for comment create (TASK-3585)

POST .../items/{ref}/comments accepts op_key (body) or Idempotency-Key.
The key's answer (the comment id) is recorded inside
CreateCommentWithActivity's transaction, so a retry whose first answer
was lost returns that comment instead of a twin. A refused comment
releases its key; a replay of a comment deleted since answers 410
idempotency_target_gone. `pad item comment --op-key` (a fresh key per
invocation by default) retries once behind the idempotency_keys
capability; MCP comment forwards op_key on both transports.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* feat(items): idempotency keys for note and decision appends (TASK-3585)

PATCH /workspaces/{ws}/items/{ref} accepts op_key (body) or
Idempotency-Key, but only on an append-only update
(append_implementation_note and/or append_decision, plus source); a key
with any other member is refused 400. The answer (the item id and the
minted entries) is recorded in the update's own transaction, so a retry
whose first answer was lost echoes the first entries in `appended`
instead of appending twice. The built-in and patch callers, which reach
this handler with their own key, are untouched.

`pad item note|decide --op-key` (fresh per invocation by default) retry
once behind the idempotency_keys capability; MCP note/decide forward
op_key. ToolSurfaceVersion 0.91 -> 0.92 (additive; also records the 410
now being structured). docs/api.md covers create, comment and append keys.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 14:38:15 -04:00

695 lines
25 KiB
Go

package mcp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"sort"
"strings"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/server"
)
// 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,
) (*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,
) (*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,
) (*CallToolResult, error) {
const cmdKey = "project next"
workspace, _ := input["workspace"].(string)
if workspace == "" {
return noWorkspaceResult(ctx, d.Lister), 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,
) (*CallToolResult, error) {
const cmdKey = "project standup"
workspace, _ := input["workspace"].(string)
if workspace == "" {
return noWorkspaceResult(ctx, d.Lister), 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,
) (*CallToolResult, error) {
const cmdKey = "project changelog"
workspace, _ := input["workspace"].(string)
if workspace == "" {
return noWorkspaceResult(ctx, d.Lister), 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, *CallToolResult) {
workspace, _ := input["workspace"].(string)
if workspace == "" {
return nil, noWorkspaceResult(ctx, d.Lister)
}
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 as a per-item `fields_patch` (so other fields are
// never written, BUG-3156). Mirrors the CLI's bulkUpdateCmd:
// at-least-one-of-status-or-priority gating, one PATCH per item, 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,
) (*CallToolResult, error) {
const cmdKey = "item bulk-update"
workspace, _ := input["workspace"].(string)
if workspace == "" {
return noWorkspaceResult(ctx, d.Lister), 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 {
// BUG-3156: ONE PATCH per row carrying `fields_patch` with only the
// keys this call sets. This used to be a read-modify-write: GET the
// item, set status/priority on the DECODED blob, PATCH the whole
// blob back, which reverted any field another client wrote between
// the two (BUG-3049's defect, on the transport that fix missed; the
// CLI has sent `fields_patch` since). The server merges the patch
// under its write lock. There is no prefetch: the PATCH does its own
// visibility and not-found checks, and a GET whose only job was the
// merge is the defect's shape (the same call BUG-3056 made for
// note/decide).
itemPath := "/api/v1/workspaces/" + url.PathEscape(workspace) +
"/items/" + url.PathEscape(ref)
fieldsPatch := map[string]any{}
if status != "" {
fieldsPatch["status"] = status
}
if priority != "" {
fieldsPatch["priority"] = priority
}
// refuse_undeclared_fields (BUG-3156): status and priority are the
// operation's keys, so a collection declaring neither refuses the
// row with validation_error instead of storing an orphan field, the
// same answer stdio and POST /items/bulk (WebMCP) give.
patchPayload := map[string]any{"fields_patch": fieldsPatch, "refuse_undeclared_fields": true}
// 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,
) (*CallToolResult, error) {
const cmdKey = "item note"
workspace, _ := input["workspace"].(string)
ref, _ := input["ref"].(string)
summary, _ := input["summary"].(string)
if workspace == "" {
return noWorkspaceResult(ctx, d.Lister), 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)
// BUG-3056: the server appends under its write lock (and mints the id and
// timestamp), so a concurrent write to another key cannot be reverted by
// this one. No capability check: this dispatcher runs in-process with the
// handler it calls, so the two cannot disagree. The entry keeps this door's
// own attribution, the user's display name.
ctx = server.WithStructuredEntryAuthor(ctx, userActorLabel(user))
body, err := json.Marshal(models.ItemUpdate{
AppendImplementationNote: &models.ItemImplementationNoteAppend{
Summary: strings.TrimSpace(summary),
Details: details,
},
// TASK-3585: the append's idempotency key, when the agent sent one.
OpKey: opKeyFrom(input),
})
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,
) (*CallToolResult, error) {
const cmdKey = "item decide"
workspace, _ := input["workspace"].(string)
ref, _ := input["ref"].(string)
decision, _ := input["decision"].(string)
if workspace == "" {
return noWorkspaceResult(ctx, d.Lister), 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)
// BUG-3056 — see dispatchItemNote.
ctx = server.WithStructuredEntryAuthor(ctx, userActorLabel(user))
body, err := json.Marshal(models.ItemUpdate{
AppendDecision: &models.ItemDecisionLogAppend{
Decision: strings.TrimSpace(decision),
Rationale: rationale,
},
OpKey: opKeyFrom(input),
})
if err != nil {
return dispatcherErrorResult(cmdKey, "encode body", err), nil
}
return d.executeRequest(ctx, cmdKey, user, http.MethodPatch, itemPath, body)
}
// userActorLabel is the created_by this door writes on note/decision entries:
// the requesting user's name, falling back to email, so audit-log review can
// tell who appended what when several users share one MCP server. (The CLI
// writes the user/agent kind instead; the two doors have always differed.)
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,
) (*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, *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
}
// opKeyFrom is the agent's op_key, if any (TASK-3585).
func opKeyFrom(input map[string]any) string {
k, _ := input["op_key"].(string)
return k
}