mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
42f6ce96e1
Three independent improvements bundled as one PR because they all touch
the same dispatcher error-emission surface; landing them piecemeal
would churn the same lines repeatedly.
## TASK-1077 — uniform envelope shape
Pre-fix some dispatchers emitted plain-string errors via
`mcp.NewToolResultErrorf("%s: %s failed: %s", ...)`. Same underlying
404 surfaced in three different shapes across the surface (item
lookup → structured envelope; note/decide → "item note: prefetch:
404 ..."; bulk-update per-row → bare error string). Inconsistent
shape made it hard for agents to reason about errors uniformly.
Three new helpers in errors.go:
- validationFailedResult(cmdKey, msg, fixHint) — replaces the
"X is required" / "invalid Y" chain across every dispatcher.
- dispatcherErrorResult(cmdKey, op, err) — replaces the internal
"build request: %s" / "encode body: %s" / "parse current: %s"
chain. Always emits ErrServerError with a programmer-readable
Hint.
- upstreamHTTPErrorResult(...) — wraps every in-handler prefetch /
sub-call HTTP failure through classifyHTTPStatusKind so the shape
matches the main pipeline's responses exactly.
Every NewToolResultErrorf call site in internal/mcp/dispatch_http*.go
+ catalog.go retrofitted. bulk-update's per-row `Error string` field
flipped to `Error *ErrorPayload` so every row failure carries the
same {code, message, hint} shape as a top-level failure.
## TASK-1078 — resource-kind-aware error codes
Pre-fix every 4xx 404 collapsed to ErrItemNotFound regardless of
what was being read; pad_workspace list returning 404 (route
missing) reported `code: "item_not_found"` despite the call having
nothing to do with items. Pre-fix every 5xx collapsed to
ErrServerError, indistinguishable from dispatcher internal failures.
Three new codes in errors.go:
- ErrNotFound — resource-shaped 404s that AREN'T item lookups
(collection, listing endpoint, link target, attachment).
- ErrUpstreamError — 5xx with a structured body (transient backend
failure). Distinct from ErrServerError (catch-all for dispatcher
internal + un-mapped 4xx).
- ErrBackendUnreachable — reserved for transport-level failures
(DNS / connection refused / 5xx with no body); not yet emitted
by classifyHTTPStatus but available for future transport-aware
classification.
- ErrWorkspaceRequired — reserved for the multi-workspace-token
"ambiguous default" case (TASK-1076's deferred sister error;
constant available even though dispatcher doesn't emit it yet).
New ResourceKind enum (item/workspace/collection/listing/link/
attachment/unknown) lets callers tell the classifier what they
were reading. classifyHTTPStatusKind is the new entry point;
classifyHTTPStatus preserved as a legacy adapter for callers that
haven't been retrofitted (pass ResourceUnknown → falls back to
pre-TASK-1078 behaviour).
Every retrofit call site passes its known kind + ref/slug, so 404s
now route through the right code with a contextual message
("Item TASK-7 not found.", "Workspace foo not visible.",
"Collection tasks not found.", etc.).
## TASK-1079 — actionable hints
Pre-fix `hint` was usually `"404 page not found"` (chi's default
NotFound body verbatim) or the upstream JSON envelope re-stringified.
Either way: zero diagnostic value, sometimes outright misleading
(double-stringified JSON in a hint field is hostile).
Per-code hint generators in errors.go:
- itemMissingHint — names the ref + route + suggests pad_item
search / list as recovery.
- workspaceMissingHint — names the slug + route + composes with
the existing available_workspaces enrichment.
- notFoundHintFor — kind-aware: collection 404 → "use pad_collection
list to enumerate"; listing 404 → "verify the route matches the
server's API surface (build version may be stale)"; etc.
- authHintFor / permissionHintFor — point at re-auth / scope check.
- upstreamHintFor — flags 5xx as "usually transient — retry once or
check pad logs."
extractUpstreamMessage parses pad's own structured `{error:{message}}`
envelope when the upstream backend returned one, so hints lift the
inner human-readable message out instead of dumping the literal JSON.
Falls back to the raw body when the JSON shape doesn't match (no
parse failure noise).
## Tests
- TestDispatcher_AllErrorsUseStructuredEnvelope walks every
special-case + link dispatcher's missing-required-input error
path; pins the shape (code, message, hint all set; hint never
just "404 page not found"). Adding a new dispatcher that uses
NewToolResultErrorf will fail this test — it's the regression
gate the DOD wants.
- TestClassifyHTTPStatus_KindAware pins each ResourceKind →
expected ErrorCode mapping for 404s.
- TestClassifyHTTPStatus_HintsAreActionable pins that hints
reference the actual route + ref + recovery tools, AND forbids
the bare "404 page not found" passthrough that triggered Bug 17.
- TestExtractUpstreamMessage covers the 7 input shapes the helper
can see (structured envelope, empty inner, missing inner field,
unparseable, wrong shape, empty, with extra fields).
- Two existing tests updated to reflect the new shapes:
TestClassifyHTTPStatus 5xx cases now expect ErrUpstreamError;
TestMakeFanOutHandler_UnknownAction + TestActionEnv_Dispatch_
UnknownCmdPath substring searches updated for JSON-encoded
quotes.
## Behavior diff agents will observe
Same underlying 404, three example error envelopes:
pad_item show TASK-MISSING:
code: "item_not_found"
message: "Item not found."
hint: "Item \"TASK-MISSING\" not found. Route: /api/v1/.../items/TASK-MISSING. Try `pad_item search` or `pad_item list` to find the right ref."
pad_workspace list (route 404):
code: "unknown_workspace"
message: "Workspace not visible to this session."
hint: "Route: /api/v1/workspaces. Available workspaces: docapp, pad-web."
pad_project dashboard (workspace doesn't exist):
code: "unknown_workspace"
message: "Workspace \"missing\" is not visible to this session."
hint: "Workspace \"missing\" not visible. Route: /api/v1/workspaces/missing/dashboard. Available workspaces: docapp."
Backend 500:
code: "upstream_error"
message: "pad item show failed: backend returned 500"
hint: "Backend returned 500. Usually transient — retry once or check pad logs for the underlying error. Route: ..."
698 lines
25 KiB
Go
698 lines
25 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// itemLinkSpec wires a CLI link-command's arg shape to the underlying
|
|
// /api/v1/workspaces/{ws}/items/{slug}/links surface.
|
|
//
|
|
// The link-command surface has an asymmetry the route table can't
|
|
// express: the URL path goes through ONE item's slug while the body
|
|
// carries the OTHER item's UUID. Block/implements/supersedes/split-from
|
|
// use (source.Slug, target.ID); blocked-by inverts to (blocker.Slug,
|
|
// source.ID). The mapper would need to know which input key drives
|
|
// which side, and would still need a Handler reference to do the
|
|
// ref→ID prefetch for the body's target_id (the body shape rejects
|
|
// a raw ref).
|
|
//
|
|
// Lives as method-bound dispatchers on HTTPHandlerDispatcher rather
|
|
// than mappers in the route table for the same reason dispatchItemUpdate
|
|
// does — needs Handler to do the ref→{slug,id} prefetches before
|
|
// building the request.
|
|
type itemLinkSpec struct {
|
|
// cmdKey is the dotted command path, e.g. "item block" — used as
|
|
// the prefix on every error / IsError result so MCP clients see a
|
|
// stable identifier.
|
|
cmdKey string
|
|
|
|
// urlRefKey is the input key whose resolved item.Slug goes into
|
|
// the URL path /items/{slug}/links. For `item block` that's
|
|
// `source_ref`; for `item blocked-by` it's `blocker_ref` (the
|
|
// blocker is the link's source per the data model).
|
|
urlRefKey string
|
|
|
|
// bodyTargetRefKey is the input key whose resolved item.ID
|
|
// becomes the body's target_id. For `item block` that's
|
|
// `target_ref`; for `item blocked-by` it's `source_ref` (the
|
|
// blocked item is the link's target).
|
|
bodyTargetRefKey string
|
|
|
|
// linkType is the canonical type written on the wire — must be
|
|
// one of the constants in models/item_links.go (blocks,
|
|
// implements, supersedes, split_from).
|
|
linkType string
|
|
}
|
|
|
|
// itemLinkSpecs is the lookup table for link create/delete commands.
|
|
// Build is in init() so the package's startup-cost stays small and
|
|
// the cmdKey constants are co-located with their wiring.
|
|
//
|
|
// Read-only link commands (deps, related, implemented-by) are not
|
|
// here — those just GET /items/{ref}/links and don't need the URL/body
|
|
// asymmetry; they go through dispatchGetItemLinks instead.
|
|
var itemLinkSpecs = map[string]itemLinkSpec{
|
|
// `block`: SOURCE blocks TARGET. Link source = source_ref item,
|
|
// link target = target_ref item.
|
|
"item block": {
|
|
cmdKey: "item block",
|
|
urlRefKey: "source_ref",
|
|
bodyTargetRefKey: "target_ref",
|
|
linkType: models.ItemLinkTypeBlocks,
|
|
},
|
|
// `blocked-by`: SOURCE is blocked by BLOCKER → blocker blocks
|
|
// source. The link's source is BLOCKER, target is SOURCE.
|
|
"item blocked-by": {
|
|
cmdKey: "item blocked-by",
|
|
urlRefKey: "blocker_ref",
|
|
bodyTargetRefKey: "source_ref",
|
|
linkType: models.ItemLinkTypeBlocks,
|
|
},
|
|
"item unblock": {
|
|
cmdKey: "item unblock",
|
|
urlRefKey: "source_ref",
|
|
bodyTargetRefKey: "target_ref",
|
|
linkType: models.ItemLinkTypeBlocks,
|
|
},
|
|
"item implements": {
|
|
cmdKey: "item implements",
|
|
urlRefKey: "implementer_ref",
|
|
bodyTargetRefKey: "target_ref",
|
|
linkType: models.ItemLinkTypeImplements,
|
|
},
|
|
"item unimplements": {
|
|
cmdKey: "item unimplements",
|
|
urlRefKey: "implementer_ref",
|
|
bodyTargetRefKey: "target_ref",
|
|
linkType: models.ItemLinkTypeImplements,
|
|
},
|
|
"item supersedes": {
|
|
cmdKey: "item supersedes",
|
|
urlRefKey: "new_ref",
|
|
bodyTargetRefKey: "old_ref",
|
|
linkType: models.ItemLinkTypeSupersedes,
|
|
},
|
|
"item unsupersede": {
|
|
cmdKey: "item unsupersede",
|
|
urlRefKey: "new_ref",
|
|
bodyTargetRefKey: "old_ref",
|
|
linkType: models.ItemLinkTypeSupersedes,
|
|
},
|
|
"item split-from": {
|
|
cmdKey: "item split-from",
|
|
urlRefKey: "child_ref",
|
|
bodyTargetRefKey: "parent_ref",
|
|
linkType: models.ItemLinkTypeSplitFrom,
|
|
},
|
|
"item unsplit": {
|
|
cmdKey: "item unsplit",
|
|
urlRefKey: "child_ref",
|
|
bodyTargetRefKey: "parent_ref",
|
|
linkType: models.ItemLinkTypeSplitFrom,
|
|
},
|
|
}
|
|
|
|
// itemPrefetch is the shape resolveItemRef returns. Only exposes the
|
|
// fields the link dispatchers need so callers can't accidentally lean
|
|
// on something that's only sometimes populated.
|
|
type itemPrefetch struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
}
|
|
|
|
// resolveItemRef does a GET /api/v1/workspaces/{ws}/items/{ref} and
|
|
// returns the resolved id+slug. Used by the link dispatchers to
|
|
// translate user-friendly refs (TASK-5, item slugs, UUIDs) into the
|
|
// {slug for URL, id for body} pair the /links surface expects.
|
|
//
|
|
// Goes through buildAuthedRequest so any OAuth-scope context attached
|
|
// at dispatch time (d.Apply) applies to the prefetch the same way it
|
|
// applies to the main request — same scope-bypass-prevention reasoning
|
|
// behind dispatchItemUpdate's prefetch (Codex review #345 round 1).
|
|
func (d *HTTPHandlerDispatcher) resolveItemRef(
|
|
ctx context.Context,
|
|
user *models.User,
|
|
workspace, ref string,
|
|
) (*itemPrefetch, error) {
|
|
path := "/api/v1/workspaces/" + url.PathEscape(workspace) +
|
|
"/items/" + url.PathEscape(ref)
|
|
req, err := d.buildAuthedRequest(ctx, http.MethodGet, path, nil, user)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build prefetch request for %q: %w", ref, err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
d.Handler.ServeHTTP(rec, req)
|
|
if rec.Code >= 400 {
|
|
body := strings.TrimSpace(rec.Body.String())
|
|
if body == "" {
|
|
body = http.StatusText(rec.Code)
|
|
}
|
|
return nil, fmt.Errorf("resolve %q: %d %s", ref, rec.Code, body)
|
|
}
|
|
var out itemPrefetch
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
return nil, fmt.Errorf("parse item prefetch for %q: %w", ref, err)
|
|
}
|
|
if out.ID == "" || out.Slug == "" {
|
|
return nil, fmt.Errorf("resolve %q: response missing id or slug", ref)
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// dispatchCreateItemLink handles the create-side of the link surface
|
|
// (block, blocked-by, implements, supersedes, split-from). Resolves
|
|
// both refs, then POSTs to /items/{urlSlug}/links with body
|
|
// {target_id: <UUID>, link_type: <type>}.
|
|
//
|
|
// Mirrors the CLI's createLineageLink (cmd/pad/lineage.go) and
|
|
// blocksCmd / blockedByCmd (cmd/pad/main.go). The behaviour is
|
|
// identical: prefetch source + target, then create the link in the
|
|
// canonical direction the data model expects.
|
|
func (d *HTTPHandlerDispatcher) dispatchCreateItemLink(
|
|
ctx context.Context,
|
|
input map[string]any,
|
|
user *models.User,
|
|
spec itemLinkSpec,
|
|
) (*mcp.CallToolResult, error) {
|
|
workspace, _ := input["workspace"].(string)
|
|
if workspace == "" {
|
|
return validationFailedResult(spec.cmdKey, "workspace is required",
|
|
"Pass `workspace=<slug>` or set a session default via pad_set_workspace."), nil
|
|
}
|
|
|
|
urlRef, _ := input[spec.urlRefKey].(string)
|
|
if urlRef == "" {
|
|
return validationFailedResult(spec.cmdKey,
|
|
fmt.Sprintf("%s is required", spec.urlRefKey),
|
|
fmt.Sprintf("Pass `%s=<TASK-N>` (the source side of the link).", spec.urlRefKey)), nil
|
|
}
|
|
bodyRef, _ := input[spec.bodyTargetRefKey].(string)
|
|
if bodyRef == "" {
|
|
return validationFailedResult(spec.cmdKey,
|
|
fmt.Sprintf("%s is required", spec.bodyTargetRefKey),
|
|
fmt.Sprintf("Pass `%s=<TASK-N>` (the target side of the link).", spec.bodyTargetRefKey)), nil
|
|
}
|
|
|
|
urlItem, err := d.resolveItemRef(ctx, user, workspace, urlRef)
|
|
if err != nil {
|
|
return validationFailedResult(spec.cmdKey, err.Error(),
|
|
fmt.Sprintf("Verify item %q exists in workspace %q (use pad_item search / list).", urlRef, workspace)), nil
|
|
}
|
|
bodyItem, err := d.resolveItemRef(ctx, user, workspace, bodyRef)
|
|
if err != nil {
|
|
return validationFailedResult(spec.cmdKey, err.Error(),
|
|
fmt.Sprintf("Verify item %q exists in workspace %q (use pad_item search / list).", bodyRef, workspace)), nil
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"target_id": bodyItem.ID,
|
|
"link_type": spec.linkType,
|
|
}
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return dispatcherErrorResult(spec.cmdKey, "encode body", err), nil
|
|
}
|
|
|
|
urlPath := "/api/v1/workspaces/" + url.PathEscape(workspace) +
|
|
"/items/" + url.PathEscape(urlItem.Slug) + "/links"
|
|
return d.executeRequest(ctx, spec.cmdKey, user, http.MethodPost, urlPath, body)
|
|
}
|
|
|
|
// dispatchDeleteItemLink handles the un-* side of the link surface
|
|
// (unblock, unimplements, unsupersede, unsplit). Mirrors the CLI's
|
|
// deleteLineageLink + unblockCmd: resolve both refs, list links on
|
|
// the source item, find the one matching (source.ID, target.ID,
|
|
// link_type), and DELETE it by id.
|
|
//
|
|
// Returns IsError when no matching link exists — same UX the CLI
|
|
// surfaces ("no <type> relationship found"). Surfacing the same
|
|
// missing-link error keeps the behaviour identical across transports.
|
|
func (d *HTTPHandlerDispatcher) dispatchDeleteItemLink(
|
|
ctx context.Context,
|
|
input map[string]any,
|
|
user *models.User,
|
|
spec itemLinkSpec,
|
|
) (*mcp.CallToolResult, error) {
|
|
workspace, _ := input["workspace"].(string)
|
|
if workspace == "" {
|
|
return validationFailedResult(spec.cmdKey, "workspace is required",
|
|
"Pass `workspace=<slug>` or set a session default via pad_set_workspace."), nil
|
|
}
|
|
|
|
urlRef, _ := input[spec.urlRefKey].(string)
|
|
if urlRef == "" {
|
|
return validationFailedResult(spec.cmdKey,
|
|
fmt.Sprintf("%s is required", spec.urlRefKey),
|
|
fmt.Sprintf("Pass `%s=<TASK-N>` (the source side of the link to remove).", spec.urlRefKey)), nil
|
|
}
|
|
bodyRef, _ := input[spec.bodyTargetRefKey].(string)
|
|
if bodyRef == "" {
|
|
return validationFailedResult(spec.cmdKey,
|
|
fmt.Sprintf("%s is required", spec.bodyTargetRefKey),
|
|
fmt.Sprintf("Pass `%s=<TASK-N>` (the target side of the link to remove).", spec.bodyTargetRefKey)), nil
|
|
}
|
|
|
|
urlItem, err := d.resolveItemRef(ctx, user, workspace, urlRef)
|
|
if err != nil {
|
|
return validationFailedResult(spec.cmdKey, err.Error(),
|
|
fmt.Sprintf("Verify item %q exists in workspace %q.", urlRef, workspace)), nil
|
|
}
|
|
bodyItem, err := d.resolveItemRef(ctx, user, workspace, bodyRef)
|
|
if err != nil {
|
|
return validationFailedResult(spec.cmdKey, err.Error(),
|
|
fmt.Sprintf("Verify item %q exists in workspace %q.", bodyRef, workspace)), nil
|
|
}
|
|
|
|
links, err := d.listItemLinks(ctx, user, workspace, urlItem.Slug)
|
|
if err != nil {
|
|
return dispatcherErrorResult(spec.cmdKey, "list links", err), nil
|
|
}
|
|
|
|
canonicalType, normErr := models.NormalizeItemLinkType(spec.linkType)
|
|
if normErr != nil {
|
|
// Programming error — only canonical types belong in itemLinkSpecs.
|
|
return dispatcherErrorResult(spec.cmdKey, "validate link type",
|
|
fmt.Errorf("invalid link type %q", spec.linkType)), nil
|
|
}
|
|
|
|
var linkID string
|
|
for _, link := range links {
|
|
if link.SourceID != urlItem.ID || link.TargetID != bodyItem.ID {
|
|
continue
|
|
}
|
|
got, err := models.NormalizeItemLinkType(link.LinkType)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if got == canonicalType {
|
|
linkID = link.ID
|
|
break
|
|
}
|
|
}
|
|
if linkID == "" {
|
|
return NewErrorResult(ErrorPayload{
|
|
Code: ErrNotFound,
|
|
Message: fmt.Sprintf("%s: no %s relationship found between %s and %s",
|
|
spec.cmdKey, spec.linkType, urlRef, bodyRef),
|
|
Hint: fmt.Sprintf("Use pad_item action=deps to see existing relationships on %q.", urlRef),
|
|
}), nil
|
|
}
|
|
|
|
urlPath := "/api/v1/workspaces/" + url.PathEscape(workspace) +
|
|
"/links/" + url.PathEscape(linkID)
|
|
res, err := d.executeRequest(ctx, spec.cmdKey, user, http.MethodDelete, urlPath, nil)
|
|
if err != nil || res.IsError {
|
|
return res, err
|
|
}
|
|
// Handler returns 204 No Content — packageHTTPResponse turns an
|
|
// empty body into an empty TextContent, which is uninformative
|
|
// for MCP clients. Mirror the CLI's `--format json` output for
|
|
// these commands (cmd/pad/main.go's unblockCmd / lineage
|
|
// deleteLineageLink: `{"status":"removed"}`) so MCP gets a
|
|
// structured success signal. Goes through packageStructuredResponse
|
|
// so the StructuredContent is a JSON-decoded `map[string]any`,
|
|
// matching what the rest of the dispatcher emits.
|
|
return packageStructuredResponse(spec.cmdKey, map[string]string{"status": "removed"})
|
|
}
|
|
|
|
// dispatchItemDeps handles `pad item deps <ref>` — the simplest of
|
|
// the three read-only link queries. CLI parity: `deps --format json`
|
|
// returns the raw `/links` array, so we just GET-and-package.
|
|
func (d *HTTPHandlerDispatcher) dispatchItemDeps(
|
|
ctx context.Context,
|
|
input map[string]any,
|
|
user *models.User,
|
|
) (*mcp.CallToolResult, error) {
|
|
const cmdKey = "item deps"
|
|
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
|
|
}
|
|
ref, _ := input["ref"].(string)
|
|
if ref == "" {
|
|
return validationFailedResult(cmdKey, "ref is required",
|
|
"Pass `ref=<TASK-N>` (the item whose dependencies you're querying)."), nil
|
|
}
|
|
|
|
urlPath := "/api/v1/workspaces/" + url.PathEscape(workspace) +
|
|
"/items/" + url.PathEscape(ref) + "/links"
|
|
return d.executeRequest(ctx, cmdKey, user, http.MethodGet, urlPath, nil)
|
|
}
|
|
|
|
// relatedEntry / relatedGroup mirror the CLI's `--format json` output
|
|
// for `item related` / `item implemented-by` (cmd/pad/query.go's
|
|
// types of the same name). Reproduced here because the CLI types are
|
|
// in package main and not importable; field shapes match exactly so
|
|
// MCP clients see the same JSON they'd see through the CLI.
|
|
type relatedEntry struct {
|
|
Ref string `json:"ref,omitempty"`
|
|
Title string `json:"title"`
|
|
CollectionSlug string `json:"collection_slug,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
}
|
|
|
|
type relatedGroup struct {
|
|
Key string `json:"key"`
|
|
Label string `json:"label"`
|
|
Entries []relatedEntry `json:"entries"`
|
|
}
|
|
|
|
// dispatchItemRelated handles `pad item related <ref>` and emits the
|
|
// grouped response shape the CLI's `--format json` output uses
|
|
// (cmd/pad/query.go relatedCmd):
|
|
//
|
|
// {"item_ref":..., "item_title":..., "collection":...,
|
|
// "group_count": N, "groups": [{"key", "label", "entries":[...]}, ...]}
|
|
//
|
|
// Codex review on PR #346 caught the original raw-links shape as a
|
|
// behavioural divergence from the CLI; fixing it preserves the
|
|
// "transport-equivalent to ExecDispatcher" contract this dispatcher
|
|
// is built on.
|
|
func (d *HTTPHandlerDispatcher) dispatchItemRelated(
|
|
ctx context.Context,
|
|
input map[string]any,
|
|
user *models.User,
|
|
) (*mcp.CallToolResult, error) {
|
|
const cmdKey = "item related"
|
|
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
|
|
}
|
|
ref, _ := input["ref"].(string)
|
|
if ref == "" {
|
|
return validationFailedResult(cmdKey, "ref is required",
|
|
"Pass `ref=<TASK-N>` (the item whose related items you're querying)."), nil
|
|
}
|
|
|
|
item, err := d.fetchItem(ctx, user, workspace, ref)
|
|
if err != nil {
|
|
return validationFailedResult(cmdKey, err.Error(),
|
|
fmt.Sprintf("Verify item %q exists in workspace %q.", ref, workspace)), nil
|
|
}
|
|
links, err := d.listItemLinks(ctx, user, workspace, item.Slug)
|
|
if err != nil {
|
|
return dispatcherErrorResult(cmdKey, "list links", err), nil
|
|
}
|
|
|
|
groups := buildRelatedGroups(item, links)
|
|
payload := map[string]any{
|
|
"item_ref": itemRefString(item),
|
|
"item_title": item.Title,
|
|
"collection": item.CollectionSlug,
|
|
"group_count": len(groups),
|
|
"groups": groups,
|
|
}
|
|
return packageStructuredResponse(cmdKey, payload)
|
|
}
|
|
|
|
// dispatchItemImplementedBy handles `pad item implemented-by <ref>`
|
|
// with the CLI's filtered shape (cmd/pad/query.go implementedByCmd):
|
|
//
|
|
// {"item_ref":..., "item_title":..., "count": N,
|
|
// "results": [{"ref","title","collection_slug","status"}, ...]}
|
|
//
|
|
// Filters to INCOMING `implements` links only — outgoing implements
|
|
// links are excluded because they describe what THIS item implements,
|
|
// not what implements it.
|
|
func (d *HTTPHandlerDispatcher) dispatchItemImplementedBy(
|
|
ctx context.Context,
|
|
input map[string]any,
|
|
user *models.User,
|
|
) (*mcp.CallToolResult, error) {
|
|
const cmdKey = "item implemented-by"
|
|
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
|
|
}
|
|
ref, _ := input["ref"].(string)
|
|
if ref == "" {
|
|
return validationFailedResult(cmdKey, "ref is required",
|
|
"Pass `ref=<TASK-N>` (the item whose implementers you're querying)."), nil
|
|
}
|
|
|
|
item, err := d.fetchItem(ctx, user, workspace, ref)
|
|
if err != nil {
|
|
return validationFailedResult(cmdKey, err.Error(),
|
|
fmt.Sprintf("Verify item %q exists in workspace %q.", ref, workspace)), nil
|
|
}
|
|
links, err := d.listItemLinks(ctx, user, workspace, item.Slug)
|
|
if err != nil {
|
|
return dispatcherErrorResult(cmdKey, "list links", err), nil
|
|
}
|
|
|
|
results := incomingImplementedBy(item, links)
|
|
payload := map[string]any{
|
|
"item_ref": itemRefString(item),
|
|
"item_title": item.Title,
|
|
"count": len(results),
|
|
"results": results,
|
|
}
|
|
return packageStructuredResponse(cmdKey, payload)
|
|
}
|
|
|
|
// packageStructuredResponse encodes payload to JSON, then decodes it
|
|
// back to a generic any so the StructuredContent surface matches the
|
|
// shape MCP clients see over the wire — `map[string]any` / `[]any` /
|
|
// JSON-decoded primitives — rather than the originally-typed Go
|
|
// struct slices.
|
|
//
|
|
// This matches packageHTTPResponse's pattern: that helper json-decodes
|
|
// the handler's response body into `any` for the structured channel,
|
|
// so synthesized responses use the same path here for shape parity.
|
|
func packageStructuredResponse(cmdKey string, payload any) (*mcp.CallToolResult, error) {
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return dispatcherErrorResult(cmdKey, "encode response", err), nil
|
|
}
|
|
var decoded any
|
|
if err := json.Unmarshal(body, &decoded); err != nil {
|
|
// Should be unreachable — we just marshalled a known-good
|
|
// payload — but if it ever happens, fall back to the typed
|
|
// payload + raw body so the caller still gets something
|
|
// usable instead of an error.
|
|
return mcp.NewToolResultStructured(payload, string(body)), nil
|
|
}
|
|
return mcp.NewToolResultStructured(decoded, string(body)), nil
|
|
}
|
|
|
|
// fetchItem retrieves a full models.Item via the
|
|
// /api/v1/workspaces/{ws}/items/{ref} surface. Used by the related /
|
|
// implemented-by dispatchers which need title + collection_slug +
|
|
// computed ref for the response wrapper. Goes through buildAuthedRequest
|
|
// so d.Apply (OAuth scope context) applies to the prefetch — same
|
|
// scope-bypass-prevention reasoning behind dispatchItemUpdate's GET.
|
|
func (d *HTTPHandlerDispatcher) fetchItem(
|
|
ctx context.Context,
|
|
user *models.User,
|
|
workspace, ref string,
|
|
) (*models.Item, error) {
|
|
path := "/api/v1/workspaces/" + url.PathEscape(workspace) +
|
|
"/items/" + url.PathEscape(ref)
|
|
req, err := d.buildAuthedRequest(ctx, http.MethodGet, path, nil, user)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build item request for %q: %w", ref, err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
d.Handler.ServeHTTP(rec, req)
|
|
if rec.Code >= 400 {
|
|
body := strings.TrimSpace(rec.Body.String())
|
|
if body == "" {
|
|
body = http.StatusText(rec.Code)
|
|
}
|
|
return nil, fmt.Errorf("resolve %q: %d %s", ref, rec.Code, body)
|
|
}
|
|
var item models.Item
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &item); err != nil {
|
|
return nil, fmt.Errorf("parse item response for %q: %w", ref, err)
|
|
}
|
|
return &item, nil
|
|
}
|
|
|
|
// itemRefString returns "TASK-5"-style refs for non-nil items, or
|
|
// the empty string when the collection prefix or item number is
|
|
// missing. Mirrors cli.ItemRef but operates on a pointer so the
|
|
// dispatchers can pass *models.Item without dereferencing.
|
|
func itemRefString(item *models.Item) string {
|
|
if item == nil || item.CollectionPrefix == "" || item.ItemNumber == nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%s-%d", item.CollectionPrefix, *item.ItemNumber)
|
|
}
|
|
|
|
// buildRelatedGroups mirrors cmd/pad/query.go's function of the same
|
|
// name. Reproduced here because the CLI version is in package main.
|
|
// Groups every link touching `item` by canonical type + direction
|
|
// (split_from vs split_into, supersedes vs superseded_by, etc.) and
|
|
// returns a stable-ordered list.
|
|
func buildRelatedGroups(item *models.Item, links []models.ItemLink) []relatedGroup {
|
|
if item == nil || len(links) == 0 {
|
|
return []relatedGroup{}
|
|
}
|
|
|
|
type groupDef struct{ label string }
|
|
definitions := map[string]groupDef{
|
|
"blocks": {label: "Blocks"},
|
|
"blocked_by": {label: "Blocked by"},
|
|
"links_to": {label: "Links to"},
|
|
"referenced_by": {label: "Referenced by"},
|
|
"split_from": {label: "Split from"},
|
|
"split_into": {label: "Split into"},
|
|
"supersedes": {label: "Supersedes"},
|
|
"superseded_by": {label: "Superseded by"},
|
|
"implements": {label: "Implements"},
|
|
"implemented_by": {label: "Implemented by"},
|
|
"related": {label: "Related"},
|
|
}
|
|
order := []string{
|
|
"blocks", "blocked_by",
|
|
"links_to", "referenced_by",
|
|
"split_from", "split_into",
|
|
"supersedes", "superseded_by",
|
|
"implements", "implemented_by",
|
|
"related",
|
|
}
|
|
|
|
grouped := map[string][]relatedEntry{}
|
|
for _, link := range links {
|
|
linkType, err := models.NormalizeItemLinkType(link.LinkType)
|
|
if err != nil {
|
|
linkType = models.ItemLinkTypeRelated
|
|
}
|
|
isSource := link.SourceID == item.ID
|
|
|
|
switch linkType {
|
|
case models.ItemLinkTypeBlocks:
|
|
if isSource {
|
|
grouped["blocks"] = append(grouped["blocks"], relatedEntryFromLink(link, false))
|
|
} else {
|
|
grouped["blocked_by"] = append(grouped["blocked_by"], relatedEntryFromLink(link, true))
|
|
}
|
|
case models.ItemLinkTypeWikiLink:
|
|
if isSource {
|
|
grouped["links_to"] = append(grouped["links_to"], relatedEntryFromLink(link, false))
|
|
} else {
|
|
grouped["referenced_by"] = append(grouped["referenced_by"], relatedEntryFromLink(link, true))
|
|
}
|
|
case models.ItemLinkTypeSplitFrom:
|
|
if isSource {
|
|
grouped["split_from"] = append(grouped["split_from"], relatedEntryFromLink(link, false))
|
|
} else {
|
|
grouped["split_into"] = append(grouped["split_into"], relatedEntryFromLink(link, true))
|
|
}
|
|
case models.ItemLinkTypeSupersedes:
|
|
if isSource {
|
|
grouped["supersedes"] = append(grouped["supersedes"], relatedEntryFromLink(link, false))
|
|
} else {
|
|
grouped["superseded_by"] = append(grouped["superseded_by"], relatedEntryFromLink(link, true))
|
|
}
|
|
case models.ItemLinkTypeImplements:
|
|
if isSource {
|
|
grouped["implements"] = append(grouped["implements"], relatedEntryFromLink(link, false))
|
|
} else {
|
|
grouped["implemented_by"] = append(grouped["implemented_by"], relatedEntryFromLink(link, true))
|
|
}
|
|
default:
|
|
grouped["related"] = append(grouped["related"], relatedEntryFromLink(link, !isSource))
|
|
}
|
|
}
|
|
|
|
results := make([]relatedGroup, 0, len(order))
|
|
for _, key := range order {
|
|
entries := grouped[key]
|
|
if len(entries) == 0 {
|
|
continue
|
|
}
|
|
results = append(results, relatedGroup{
|
|
Key: key,
|
|
Label: definitions[key].label,
|
|
Entries: entries,
|
|
})
|
|
}
|
|
return results
|
|
}
|
|
|
|
// incomingImplementedBy mirrors cmd/pad/query.go's helper. Filters
|
|
// the link list to incoming `implements` links only — outgoing
|
|
// implements describe what THIS item implements, which is the
|
|
// reverse of what callers want.
|
|
func incomingImplementedBy(item *models.Item, links []models.ItemLink) []relatedEntry {
|
|
if item == nil {
|
|
return []relatedEntry{}
|
|
}
|
|
results := make([]relatedEntry, 0, len(links))
|
|
for _, link := range links {
|
|
linkType, err := models.NormalizeItemLinkType(link.LinkType)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if linkType != models.ItemLinkTypeImplements || link.TargetID != item.ID {
|
|
continue
|
|
}
|
|
results = append(results, relatedEntryFromLink(link, true))
|
|
}
|
|
return results
|
|
}
|
|
|
|
// relatedEntryFromLink projects a link's source-side or target-side
|
|
// metadata into a relatedEntry. Mirrors cmd/pad/query.go's helper.
|
|
func relatedEntryFromLink(link models.ItemLink, useSource bool) relatedEntry {
|
|
if useSource {
|
|
return relatedEntry{
|
|
Ref: link.SourceRef,
|
|
Title: link.SourceTitle,
|
|
CollectionSlug: link.SourceCollectionSlug,
|
|
Status: link.SourceStatus,
|
|
}
|
|
}
|
|
return relatedEntry{
|
|
Ref: link.TargetRef,
|
|
Title: link.TargetTitle,
|
|
CollectionSlug: link.TargetCollectionSlug,
|
|
Status: link.TargetStatus,
|
|
}
|
|
}
|
|
|
|
// listItemLinks issues an in-handler GET against
|
|
// /api/v1/workspaces/{ws}/items/{slug}/links and decodes the response
|
|
// into models.ItemLink so the un-* dispatchers can find the matching
|
|
// link to delete.
|
|
func (d *HTTPHandlerDispatcher) listItemLinks(
|
|
ctx context.Context,
|
|
user *models.User,
|
|
workspace, itemSlug string,
|
|
) ([]models.ItemLink, error) {
|
|
path := "/api/v1/workspaces/" + url.PathEscape(workspace) +
|
|
"/items/" + url.PathEscape(itemSlug) + "/links"
|
|
req, err := d.buildAuthedRequest(ctx, http.MethodGet, path, nil, user)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build links request: %w", err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
d.Handler.ServeHTTP(rec, req)
|
|
if rec.Code >= 400 {
|
|
body := strings.TrimSpace(rec.Body.String())
|
|
if body == "" {
|
|
body = http.StatusText(rec.Code)
|
|
}
|
|
return nil, fmt.Errorf("list item links: %d %s", rec.Code, body)
|
|
}
|
|
var links []models.ItemLink
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &links); err != nil {
|
|
return nil, fmt.Errorf("parse links response: %w", err)
|
|
}
|
|
return links, nil
|
|
}
|