Files
pad/internal/mcp/dispatch_http_attachments.go
xarmian 8cdfb8e287 feat(mcp): remote /mcp resource parity — wire read-only resources onto the cloud transport (TASK-2101) (#934)
* feat(mcp): wire read-only resources onto remote /mcp transport (TASK-2101)

The cloud /mcp Streamable HTTP transport registered zero resources
("resources_wired: false") — the stdio ExecResourceFetcher shells out to
the pad binary with one user's ~/.pad credentials, unusable in the shared
multi-OAuth-user process, so resources (incl. PR #930's attachment image
resource) were deferred.

Add HTTPResourceFetcher: the in-process equivalent that dispatches each
resource read through the same pad-cloud handler chain, reusing
HTTPHandlerDispatcher's user resolution + buildAuthedRequest (token-scope
check, verified-email gate, consent Apply). It reproduces each CLI
--format json shape (item list -> cli.ToItemSummaries; workspace list ->
{slug,name,updated_at}; attachment show -> HEAD-header synth; dashboard/
collections/bootstrap/item show -> endpoint body). Because it satisfies
ResourceFetcher + BinaryResourceFetcher, RegisterResources wires the full
read-only set onto the remote transport with the SAME handlers stdio uses
(formatItemAsMarkdown, attachment bounds/sniff/base64) — zero duplication.

Attachment bytes flow through cappedResponseWriter (wrapping the existing
cappedWriter) preserving PR #933's 1 MiB download bound in the shared
process. mcp-go propagates the HTTP request context (WithCurrentUser) into
resource handlers, so auth/scope/consent parity with tool calls holds.

- item list resource matches CLI `--all` (lifts non_terminal only; does
  NOT set include_archived — soft-deleted items stay hidden).
- Shared synthesizeAttachmentMetadata between the pad_attachment tool and
  the resource fetcher so the HEAD-derived shape can't drift.

No ToolSurfaceVersion bump — resources aren't part of the tool catalog
contract (PR #930 precedent).

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

* fix(mcp): scope workspaces resource by OAuth consent allow-list per Codex review (round 1)

The pad://workspaces resource shelled GET /api/v1/workspaces, whose handler
returns every membership without consulting the OAuth token's allowed_workspaces
consent list (unlike per-workspace routes). On the remote transport a token
consented only for workspace alpha could enumerate names/slugs of unconsented
workspaces. Filter with the same rule the error-hint lister uses (buildAllowSet):
nil/wildcard allow-list -> no filter (PAT + local stdio unaffected); a specific
allow-list -> intersect with memberships.

Note: the pad_workspace list TOOL hits the same endpoint and has the same
unfiltered behavior — a pre-existing, broader concern to address at the
handler/tool level separately.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-14 19:21:58 -04:00

213 lines
7.4 KiB
Go

package mcp
import (
"context"
"fmt"
goMime "mime"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"github.com/mark3labs/mcp-go/mcp"
"github.com/PerpetualSoftware/pad/internal/models"
)
// dispatchAttachmentList handles `pad attachment list` — pure metadata
// query against /api/v1/workspaces/{ws}/attachments.
//
// Custom dispatcher (rather than a routeSpec) for two reasons:
//
// 1. `--item <ref>` needs ref→UUID resolution. The handler reads
// `item_id` (UUID); the CLI resolves the ref via GetItem before
// calling the API. Without resolution, an agent passing
// `item=TASK-5` would get an empty result silently.
// 2. `--attached` / `--unattached` are mutex booleans on the CLI
// side that fold into a single `item=attached|unattached` query
// param. The dispatcher does the same fold (and rejects the
// mutex violation) so MCP behaviour matches the CLI's
// pre-flight validation.
//
// Also returns the same `{attachments, total, limit, offset}` shape
// the handler emits — that's the parity contract.
func (d *HTTPHandlerDispatcher) dispatchAttachmentList(
ctx context.Context,
input map[string]any,
user *models.User,
) (*mcp.CallToolResult, error) {
const cmdKey = "attachment list"
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
}
attached, _ := input["attached"].(bool)
unattached, _ := input["unattached"].(bool)
if attached && unattached {
return validationFailedResult(cmdKey,
"attached and unattached are mutually exclusive",
"Pass at most one of `attached=true` or `unattached=true`."), nil
}
itemRef, _ := input["item"].(string)
if itemRef != "" && unattached {
return validationFailedResult(cmdKey,
"item and unattached are mutually exclusive",
"`item=<ref>` filters to that item's attachments; `unattached=true` filters to attachments NOT linked to any item. Drop one."), nil
}
q := url.Values{}
if s, _ := input["category"].(string); s != "" {
q.Set("category", s)
}
if s, _ := input["collection"].(string); s != "" {
q.Set("collection", s)
}
if s, _ := input["sort"].(string); s != "" {
q.Set("sort", s)
}
if n, ok := numericInput(input["limit"]); ok && n > 0 {
q.Set("limit", strconv.FormatInt(n, 10))
}
if n, ok := numericInput(input["offset"]); ok && n > 0 {
q.Set("offset", strconv.FormatInt(n, 10))
}
switch {
case attached:
q.Set("item", "attached")
case unattached:
q.Set("item", "unattached")
}
// Resolve --item ref → item_id UUID. Goes through the existing
// resolveItemRef helper that the link dispatchers use, so the
// Apply (OAuth-scope) hook applies uniformly.
if itemRef != "" {
resolved, err := d.resolveItemRef(ctx, user, workspace, itemRef)
if err != nil {
return validationFailedResult(cmdKey, "resolve --item: "+err.Error(),
fmt.Sprintf("Verify item %q exists in workspace %q (use pad_item search / list).", itemRef, workspace)), nil
}
q.Set("item_id", resolved.ID)
}
urlPath := "/api/v1/workspaces/" + url.PathEscape(workspace) + "/attachments"
if encoded := q.Encode(); encoded != "" {
urlPath += "?" + encoded
}
return d.executeRequest(ctx, cmdKey, user, http.MethodGet, urlPath, nil)
}
// dispatchAttachmentShow handles `pad attachment show <attachment-id>
// [--variant ...]` — metadata-only HEAD request.
//
// The HEAD response has no body; the metadata lives in headers
// (Content-Type, Content-Length, Content-Disposition, ETag,
// Last-Modified). We extract those into a JSON object that mirrors
// the CLI's `--format json` output (cmd/pad/main.go attachmentShowCmd):
//
// {id, mime, size, filename?, etag?, last_modified?}
//
// Custom dispatcher because packageHTTPResponse expects a JSON body —
// HEAD responses always have an empty body, so we'd otherwise return
// empty TextContent which is uninformative.
func (d *HTTPHandlerDispatcher) dispatchAttachmentShow(
ctx context.Context,
input map[string]any,
user *models.User,
) (*mcp.CallToolResult, error) {
const cmdKey = "attachment show"
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
}
attachmentID, _ := input["attachment_id"].(string)
if attachmentID == "" {
return validationFailedResult(cmdKey, "attachment_id is required",
"Pass `attachment_id=<id>` (use pad_item show to see an item's attachments)."), nil
}
urlPath := "/api/v1/workspaces/" + url.PathEscape(workspace) +
"/attachments/" + url.PathEscape(attachmentID)
if v, _ := input["variant"].(string); v != "" {
urlPath += "?variant=" + url.QueryEscape(v)
}
req, err := d.buildAuthedRequest(ctx, http.MethodHead, urlPath, nil, user)
if err != nil {
return dispatcherErrorResult(cmdKey, "build request", err), nil
}
rec := httptest.NewRecorder()
d.Handler.ServeHTTP(rec, req)
if rec.Code >= 400 {
return upstreamHTTPErrorResult(ctx, cmdKey, "fetch attachment metadata", urlPath,
rec.Code, rec.Body.Bytes(), d.Lister, ResourceAttachment, attachmentID), nil
}
return packageStructuredResponse(cmdKey, synthesizeAttachmentMetadata(rec.Result().Header, attachmentID))
}
// synthesizeAttachmentMetadata maps an attachment HEAD response's headers
// into the `{id, mime, size, filename?, etag?, last_modified?}` JSON shape
// that `pad attachment show --format json` emits. Shared by the pad_attachment
// tool (dispatchAttachmentShow) and the attachment resource fetcher
// (HTTPResourceFetcher.fetchAttachmentMetadata) so the two surfaces for the
// same HEAD-derived shape can't drift.
func synthesizeAttachmentMetadata(headers http.Header, id string) map[string]any {
out := map[string]any{
"id": id,
"mime": headers.Get("Content-Type"),
}
if cl := headers.Get("Content-Length"); cl != "" {
if n, err := strconv.ParseInt(cl, 10, 64); err == nil {
out["size"] = n
}
}
if filename := parseAttachmentFilename(headers.Get("Content-Disposition")); filename != "" {
out["filename"] = filename
}
if etag := headers.Get("ETag"); etag != "" {
out["etag"] = etag
}
if lm := headers.Get("Last-Modified"); lm != "" {
out["last_modified"] = lm
}
return out
}
// parseAttachmentFilename extracts the filename from a
// Content-Disposition header. Mirrors the CLI's helper of the same
// name (cmd/pad/main.go parseAttachmentFilename): defers to
// mime.ParseMediaType so quoted filenames containing semicolons —
// like `attachment; filename="a;b.png"` — round-trip correctly.
// A naive `strings.Split(";")` here would chop the filename at the
// first internal `;` and silently corrupt the result (Codex review
// on PR #350 caught this).
//
// Returns filepath.Base(name) so a server-emitted path-like value
// can't escape into a directory traversal — same defensive base
// the CLI applies even though the server is supposed to sanitize
// before emitting the header.
//
// mime.ParseMediaType handles BOTH the bare `filename="value"` and
// the RFC 5987 `filename*=UTF-8”<urlencoded>` forms automatically;
// we don't need to special-case either.
func parseAttachmentFilename(header string) string {
if header == "" {
return ""
}
_, params, err := goMime.ParseMediaType(header)
if err != nil {
return ""
}
name := params["filename"]
if name == "" {
return ""
}
return filepath.Base(name)
}