Files
pad/internal/mcp/dispatch_http_library.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

125 lines
4.4 KiB
Go

package mcp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"github.com/mark3labs/mcp-go/mcp"
"github.com/PerpetualSoftware/pad/internal/collections"
"github.com/PerpetualSoftware/pad/internal/models"
)
// --- library activate ---
// dispatchLibraryActivate reproduces `pad library activate <title>`:
// looks up a library entry by title (conventions first, then
// playbooks — same precedence the CLI uses), builds the right
// fields blob, and POSTs an item into the workspace's
// conventions/playbooks collection.
//
// Library data is sourced from internal/collections directly rather
// than via the /convention-library / /playbook-library endpoints.
// Both paths return the same data (the handlers wrap the same
// constants), and the in-process accessor avoids two extra HTTP
// round-trips per activate. The OAuth-scope hook (d.Apply) still
// runs on the eventual POST, so this isn't a scope bypass.
//
// Two minor divergences from the CLI:
//
// - The CLI uses `models.BuildConventionItemFields` for
// conventions (deals with surfaces/enforcement/commands metadata)
// but builds the playbook fields by hand. We match exactly.
// - The CLI's "conventions" / "playbooks" target collection slugs
// are hardcoded; we do the same. Workspaces from non-software
// templates may not have these collections, in which case the
// POST will 404 — same UX the CLI delivers.
func (d *HTTPHandlerDispatcher) dispatchLibraryActivate(
ctx context.Context,
input map[string]any,
user *models.User,
) (*mcp.CallToolResult, error) {
const cmdKey = "library activate"
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
}
title, _ := input["title"].(string)
if title == "" {
return validationFailedResult(cmdKey, "title is required",
"Pass `title=<library-item-title>` matching an entry in the convention or playbook library."), nil
}
if conv := collections.GetLibraryConvention(title); conv != nil {
fieldsJSON, err := models.BuildConventionItemFields("active", &models.ItemConventionMetadata{
Category: conv.Category,
Trigger: conv.Trigger,
Surfaces: conv.Surfaces,
Enforcement: conv.Enforcement,
Commands: conv.Commands,
})
if err != nil {
return dispatcherErrorResult(cmdKey, "build convention fields", err), nil
}
return d.postLibraryItem(ctx, user, workspace, "conventions", cmdKey, conv.Title, conv.Content, fieldsJSON)
}
if pb := collections.GetLibraryPlaybook(title); pb != nil {
// Forward invocation_slug + arguments only when set so legacy
// library entries (none of which carry them) seed with the
// original three-field shape. Mirrors ShipPlaybook() and the
// CLI activate path in cmd/pad/main.go's libraryActivate.
fields := map[string]any{
"status": "active",
"trigger": pb.Trigger,
"scope": pb.Scope,
}
if pb.InvocationSlug != "" {
fields["invocation_slug"] = pb.InvocationSlug
}
if len(pb.Arguments) > 0 {
fields["arguments"] = pb.Arguments
}
fieldsJSON, err := json.Marshal(fields)
if err != nil {
return dispatcherErrorResult(cmdKey, "encode playbook fields", err), nil
}
return d.postLibraryItem(ctx, user, workspace, "playbooks", cmdKey, pb.Title, pb.Content, string(fieldsJSON))
}
return NewErrorResult(ErrorPayload{
Code: ErrNotFound,
Message: fmt.Sprintf("%s: %q not found in convention or playbook library", cmdKey, title),
Hint: "Use `pad_library action=list` to enumerate available titles.",
}), nil
}
// postLibraryItem POSTs an ItemCreate body into the named
// collection's items endpoint. Shared between conventions /
// playbooks branches of dispatchLibraryActivate so the URL +
// envelope shape stays in lockstep.
func (d *HTTPHandlerDispatcher) postLibraryItem(
ctx context.Context,
user *models.User,
workspace, collection, cmdKey, title, content, fieldsJSON string,
) (*mcp.CallToolResult, error) {
payload := map[string]any{
"title": title,
"fields": fieldsJSON,
}
if content != "" {
payload["content"] = content
}
body, err := json.Marshal(payload)
if err != nil {
return dispatcherErrorResult(cmdKey, "encode body", err), nil
}
urlPath := "/api/v1/workspaces/" + url.PathEscape(workspace) +
"/collections/" + url.PathEscape(collection) + "/items"
return d.executeRequest(ctx, cmdKey, user, http.MethodPost, urlPath, body)
}