Files
pad/internal/mcp/resources.go
T
xarmian 475a70b57a fix(mcp): harden attachment image resource label + download bound (#933)
Follow-up to #930: label the blob from downloaded bytes (TOCTOU fix), bound FetchBytes buffering at the 1 MiB limit, and fix stale 'deferred to TASK-2076' docs. Adversarial-review + Codex findings; Codex CLEAN.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-14 15:56:17 -04:00

638 lines
22 KiB
Go

package mcp
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"os/exec"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
// MIME types reported in the read response. Stable across versions —
// MCP clients (Claude Desktop, Cursor) display them and may switch
// rendering paths based on the value.
const (
itemMIMEType = "text/markdown"
jsonMIMEType = "application/json"
uriPrefixWorkspace = "pad://workspace/"
resourceKindItem = "items" // /{ws}/items[/{ref}]
resourceKindDash = "dashboard"
resourceKindCollect = "collections"
resourceKindBootstrp = "bootstrap"
resourceKindAttach = "attachments"
attachmentResourceVariant = "thumb-md"
attachmentResourceMaxBytes = 1 << 20 // 1 MiB before base64 encoding
)
// WorkspacesURI is the canonical URI of the top-level workspace list
// resource. Like pad://_meta/version, it lives outside the
// pad://workspace/{ws}/... namespace because it's server-wide rather
// than scoped to a specific workspace.
const WorkspacesURI = "pad://workspaces"
// ResourceFetcher executes a pad CLI invocation and returns its
// stdout. Used by the resource layer where the contract is "fetch raw
// output"; shape conversion to MCP ResourceContents happens in the
// handler. Errors propagate as Go errors (the resource read protocol
// surfaces them as JSON-RPC errors, not IsError-flagged results).
type ResourceFetcher interface {
Fetch(ctx context.Context, args []string) (string, error)
}
// BinaryResourceFetcher is the byte-preserving counterpart to
// ResourceFetcher. Attachment resources use it because image stdout
// cannot safely pass through the text-oriented Fetch contract.
type BinaryResourceFetcher interface {
FetchBytes(ctx context.Context, args []string) ([]byte, error)
}
// ExecResourceFetcher shells out to the pad binary at Binary. Stderr
// is folded into the returned error on non-zero exit so MCP clients
// see the underlying CLI message.
type ExecResourceFetcher struct {
// Binary is the path to the pad executable. Required.
Binary string
}
// Fetch runs `<Binary> <args...>` and returns stdout on success.
func (f *ExecResourceFetcher) Fetch(ctx context.Context, args []string) (string, error) {
out, err := f.run(ctx, args, 0)
return string(out), err
}
// FetchBytes runs the same pad subprocess as Fetch but preserves stdout
// as bytes for binary MCP resources. It caps how much output it retains
// so a missing or dishonest Content-Length on the metadata pre-check
// can't make us buffer an unbounded body before the size gate runs;
// retaining one byte past the limit is enough for the caller to reject.
func (f *ExecResourceFetcher) FetchBytes(ctx context.Context, args []string) ([]byte, error) {
return f.run(ctx, args, attachmentResourceMaxBytes+1)
}
// run executes the pad subprocess. When limit > 0 it retains at most
// limit bytes of stdout (draining and discarding the rest so the child
// never blocks on a full pipe) and reports an error if the output would
// have exceeded it. limit <= 0 buffers stdout in full (the text path).
func (f *ExecResourceFetcher) run(ctx context.Context, args []string, limit int64) ([]byte, error) {
if f.Binary == "" {
return nil, fmt.Errorf("resource fetcher: binary path not configured")
}
cmd := exec.CommandContext(ctx, f.Binary, args...)
var stdout bytes.Buffer
var stderr strings.Builder
var capped *cappedWriter
if limit > 0 {
capped = &cappedWriter{buf: &stdout, limit: limit}
cmd.Stdout = capped
} else {
cmd.Stdout = &stdout
}
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return nil, fmt.Errorf("pad %s: %s", strings.Join(args, " "), msg)
}
if capped != nil && capped.exceeded {
return nil, fmt.Errorf("pad %s: output exceeded %d-byte cap", strings.Join(args, " "), limit)
}
return stdout.Bytes(), nil
}
// cappedWriter buffers into buf until limit bytes, then keeps accepting
// (and silently discarding) further writes so io.Copy keeps draining the
// subprocess pipe — the child can't wedge on a full pipe — while memory
// stays bounded at limit. exceeded records whether anything was dropped.
type cappedWriter struct {
buf *bytes.Buffer
limit int64
exceeded bool
}
func (c *cappedWriter) Write(p []byte) (int, error) {
if room := c.limit - int64(c.buf.Len()); room < int64(len(p)) {
c.exceeded = true
if room > 0 {
c.buf.Write(p[:room])
}
return len(p), nil
}
return c.buf.Write(p)
}
// ExecBootstrapFetcher shells out to `pad bootstrap --workspace <ws>
// --format json` to satisfy the BootstrapFetcher interface. Used by
// pad_set_workspace's response embed (PLAN-1377 / TASK-1380); RootArgs
// carries any root-level CLI flags (e.g. --url) captured at startup so
// the bootstrap call hits the same server endpoint as everything else.
type ExecBootstrapFetcher struct {
// Binary is the path to the pad executable. Required.
Binary string
// RootArgs are root-flag tokens (e.g. ["--url", "https://api..."])
// appended to every shell-out so the bootstrap fetch lands on the
// same server endpoint as the other dispatches.
RootArgs []string
}
// Bootstrap runs `<Binary> bootstrap --workspace <ws> --format json`
// and returns the raw JSON bytes. On error returns nil + the error;
// the caller (pad_set_workspace) treats bootstrap failures as
// non-fatal — the workspace switch still succeeds and the agent can
// fetch context separately.
func (f *ExecBootstrapFetcher) Bootstrap(ctx context.Context, workspace string) ([]byte, error) {
if f.Binary == "" {
return nil, fmt.Errorf("bootstrap fetcher: binary path not configured")
}
if workspace == "" {
return nil, fmt.Errorf("bootstrap fetcher: workspace is required")
}
args := append([]string{"bootstrap", "--workspace", workspace, "--format", "json"}, f.RootArgs...)
cmd := exec.CommandContext(ctx, f.Binary, args...)
var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return nil, fmt.Errorf("pad bootstrap: %s", msg)
}
return []byte(stdout.String()), nil
}
// resources owns the workspace resource handlers and their shared state.
type resources struct {
fetcher ResourceFetcher
binaryFetcher BinaryResourceFetcher
rootArgs []string // pre-formatted root-flag tokens (e.g. ["--url", "X"])
}
// RegisterResources installs the read-only MCP resource
// templates on srv:
//
// - pad://workspace/{ws}/items/{ref} — single item markdown
// - pad://workspace/{ws}/items — list of items in workspace
// - pad://workspace/{ws}/dashboard — project dashboard JSON
// - pad://workspace/{ws}/collections — collections list JSON
// - pad://workspace/{ws}/attachments/{id} — bounded image bytes
// - pad://workspace/{ws}/bootstrap — consolidated workspace context
//
// rootFlags carries any startup-captured persistent flags (e.g. --url)
// to forward to every shell-out — same shape as RegistryOptions.RootFlags.
// Empty values are skipped.
func RegisterResources(srv *server.MCPServer, fetcher ResourceFetcher, rootFlags map[string]string) {
r := &resources{
fetcher: fetcher,
binaryFetcher: binaryFetcherFor(fetcher),
rootArgs: rootFlagsToArgs(rootFlags),
}
srv.AddResourceTemplate(
mcp.NewResourceTemplate(
"pad://workspace/{workspace}/items/{ref}",
"pad item",
mcp.WithTemplateDescription(
"Full markdown content of a single pad item identified by its ref "+
"(e.g. TASK-5, IDEA-12). Includes title, fields, body, and links.",
),
mcp.WithTemplateMIMEType(itemMIMEType),
),
r.readItem,
)
srv.AddResourceTemplate(
mcp.NewResourceTemplate(
"pad://workspace/{workspace}/items",
"pad items",
mcp.WithTemplateDescription(
"List of every item in the workspace as JSON. Useful for "+
"resource discovery before reading a specific item.",
),
mcp.WithTemplateMIMEType(jsonMIMEType),
),
r.readItems,
)
srv.AddResourceTemplate(
mcp.NewResourceTemplate(
"pad://workspace/{workspace}/dashboard",
"pad dashboard",
mcp.WithTemplateDescription(
"Computed project overview for the workspace: active items, "+
"plans, attention, blockers.",
),
mcp.WithTemplateMIMEType(jsonMIMEType),
),
r.readDashboard,
)
srv.AddResourceTemplate(
mcp.NewResourceTemplate(
"pad://workspace/{workspace}/collections",
"pad collections",
mcp.WithTemplateDescription(
"List of collections in the workspace plus their JSON Schemas.",
),
mcp.WithTemplateMIMEType(jsonMIMEType),
),
r.readCollections,
)
srv.AddResourceTemplate(
mcp.NewResourceTemplate(
"pad://workspace/{workspace}/attachments/{id}",
"pad attachment image",
mcp.WithTemplateDescription(
"Bounded image bytes for an attachment, returned as base64 through "+
"the existing thumb-md variant pipeline. Non-image and oversized "+
"attachments are rejected.",
),
),
r.readAttachment,
)
srv.AddResourceTemplate(
mcp.NewResourceTemplate(
"pad://workspace/{workspace}/bootstrap",
"pad bootstrap",
mcp.WithTemplateDescription(
"Consolidated agent context-load blob (PLAN-1377 / TASK-1379): "+
"workspace + user + collections + always-on conventions + "+
"agent roles + playbook metadata + dashboard + recent "+
"activity. One read replaces four separate calls. Hosts "+
"that prefetch resources at session start should fetch "+
"this so the agent starts with full context. Equivalent "+
"to pad_meta.action=bootstrap and pad_set_workspace's "+
"response embed.",
),
mcp.WithTemplateMIMEType(jsonMIMEType),
),
r.readBootstrap,
)
// Top-level workspace list (TASK-974). Static resource — no
// parameters in the URI, so it lives in resources/list rather
// than resources/templates/list. Lets MCP hosts prefetch the
// workspace catalog at session start so subsequent tool calls
// can pass `workspace=<slug>` without an extra round-trip.
srv.AddResource(
mcp.NewResource(
WorkspacesURI,
"pad workspaces",
mcp.WithResourceDescription(
"List of workspaces visible to the current user. "+
"Each entry: {slug, name, updated_at, default}. "+
"`default: true` flags the CWD-linked workspace "+
"(local stdio only) so agents can prefer it. "+
"Cheaper than pad_workspace.action: list when the "+
"host can prefetch.",
),
mcp.WithMIMEType(jsonMIMEType),
),
r.readWorkspaces,
)
}
func binaryFetcherFor(fetcher ResourceFetcher) BinaryResourceFetcher {
binaryFetcher, _ := fetcher.(BinaryResourceFetcher)
return binaryFetcher
}
// readItem handles pad://workspace/{ws}/items/{ref}.
//
// Why JSON-then-format instead of `--format markdown`: pad's CLI
// `--format markdown` for `item show` prints only the body content
// (item.Content), losing ref/title/fields/parent-link. The resource
// is advertised as "Full markdown content … includes title, fields,
// body, and links," so the resource layer composes the full markdown
// from the JSON response. (Codex review on TASK-946 caught this gap.)
func (r *resources) readItem(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
ws, kind, ref, err := parsePadURI(req.Params.URI)
if err != nil {
return nil, err
}
if kind != resourceKindItem || ref == "" {
return nil, fmt.Errorf("resource %q is not an item URI", req.Params.URI)
}
padArgs := []string{"item", "show", ref, "--workspace", ws, "--format", "json"}
full := append(append([]string{}, padArgs...), r.rootArgs...)
out, err := r.fetcher.Fetch(ctx, full)
if err != nil {
return nil, fmt.Errorf("read %s: %w", req.Params.URI, err)
}
md, err := formatItemAsMarkdown(out)
if err != nil {
return nil, fmt.Errorf("read %s: %w", req.Params.URI, err)
}
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: req.Params.URI,
MIMEType: itemMIMEType,
Text: md,
},
}, nil
}
// formatItemAsMarkdown turns the JSON body returned by
// `pad item show --format json` into a self-contained markdown
// document: heading with ref + title, sorted metadata fields,
// optional parent link, then the item's body content.
//
// Map-key iteration is sorted so the output is stable for tests.
func formatItemAsMarkdown(jsonBlob string) (string, error) {
var item map[string]any
if err := json.Unmarshal([]byte(jsonBlob), &item); err != nil {
return "", fmt.Errorf("parse item JSON: %w", err)
}
var b strings.Builder
// Heading: `# REF: Title`
ref, _ := item["ref"].(string)
title, _ := item["title"].(string)
switch {
case ref != "" && title != "":
fmt.Fprintf(&b, "# %s: %s\n\n", ref, title)
case title != "":
fmt.Fprintf(&b, "# %s\n\n", title)
case ref != "":
fmt.Fprintf(&b, "# %s\n\n", ref)
}
// Parent link (optional). Useful for agents to traverse the tree.
if parentRef, _ := item["parent_ref"].(string); parentRef != "" {
parentTitle, _ := item["parent_title"].(string)
if parentTitle != "" {
fmt.Fprintf(&b, "**Parent:** %s — %s\n\n", parentRef, parentTitle)
} else {
fmt.Fprintf(&b, "**Parent:** %s\n\n", parentRef)
}
}
// Structured metadata fields. The `fields` payload is a JSON
// string-encoded object on the wire — unmarshal a second time.
if fieldsStr, ok := item["fields"].(string); ok && fieldsStr != "" && fieldsStr != "{}" {
var fields map[string]any
if json.Unmarshal([]byte(fieldsStr), &fields) == nil && len(fields) > 0 {
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(&b, "- **%s:** %v\n", k, fields[k])
}
b.WriteString("\n")
}
}
// Body. Pad items often have rich markdown here already; pass
// through verbatim.
if content, _ := item["content"].(string); content != "" {
b.WriteString(content)
if !strings.HasSuffix(content, "\n") {
b.WriteString("\n")
}
}
return b.String(), nil
}
// readItems handles pad://workspace/{ws}/items.
func (r *resources) readItems(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
ws, kind, arg, err := parsePadURI(req.Params.URI)
if err != nil {
return nil, err
}
if kind != resourceKindItem || arg != "" {
return nil, fmt.Errorf("resource %q is not the items list URI", req.Params.URI)
}
return r.fetchAsResource(ctx, req.Params.URI, jsonMIMEType,
[]string{"item", "list", "--all", "--workspace", ws, "--format", "json"})
}
// readDashboard handles pad://workspace/{ws}/dashboard.
func (r *resources) readDashboard(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
ws, kind, arg, err := parsePadURI(req.Params.URI)
if err != nil {
return nil, err
}
if kind != resourceKindDash || arg != "" {
return nil, fmt.Errorf("resource %q is not the dashboard URI", req.Params.URI)
}
return r.fetchAsResource(ctx, req.Params.URI, jsonMIMEType,
[]string{"project", "dashboard", "--workspace", ws, "--format", "json"})
}
// readWorkspaces handles pad://workspaces — the top-level workspace
// list (TASK-974). Shells out to `pad workspace list --format json`
// so the resource shape stays in lockstep with the CLI's JSON output
// (and with classifyExecError's available_workspaces enrichment,
// which uses the same path).
func (r *resources) readWorkspaces(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
if req.Params.URI != WorkspacesURI {
return nil, fmt.Errorf("resource %q is not the workspaces URI", req.Params.URI)
}
return r.fetchAsResource(ctx, req.Params.URI, jsonMIMEType,
[]string{"workspace", "list", "--format", "json"})
}
// readCollections handles pad://workspace/{ws}/collections.
func (r *resources) readCollections(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
ws, kind, arg, err := parsePadURI(req.Params.URI)
if err != nil {
return nil, err
}
if kind != resourceKindCollect || arg != "" {
return nil, fmt.Errorf("resource %q is not the collections URI", req.Params.URI)
}
return r.fetchAsResource(ctx, req.Params.URI, jsonMIMEType,
[]string{"collection", "list", "--workspace", ws, "--format", "json"})
}
type attachmentResourceMetadata struct {
MIME string `json:"mime"`
Size int64 `json:"size"`
}
// readAttachment handles pad://workspace/{ws}/attachments/{id}. It
// requests the existing thumb-md path so authorization, workspace
// isolation, image processing, and small-image fallback stay owned by
// the canonical attachment HTTP endpoint.
func (r *resources) readAttachment(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
ws, kind, id, err := parsePadURI(req.Params.URI)
if err != nil {
return nil, err
}
if kind != resourceKindAttach || id == "" || strings.Contains(id, "/") {
return nil, fmt.Errorf("resource %q is not an attachment URI", req.Params.URI)
}
if r.binaryFetcher == nil {
return nil, fmt.Errorf("read %s: binary resource fetcher not configured", req.Params.URI)
}
showArgs := []string{
"attachment", "show", id,
"--workspace", ws,
"--variant", attachmentResourceVariant,
"--format", "json",
}
metadataJSON, err := r.fetcher.Fetch(ctx, append(showArgs, r.rootArgs...))
if err != nil {
return nil, fmt.Errorf("read %s metadata: %w", req.Params.URI, err)
}
var metadata attachmentResourceMetadata
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return nil, fmt.Errorf("read %s metadata: parse JSON: %w", req.Params.URI, err)
}
if !strings.HasPrefix(strings.ToLower(metadata.MIME), "image/") {
return nil, fmt.Errorf("read %s: attachment MIME %q is not an image", req.Params.URI, metadata.MIME)
}
if metadata.Size < 0 {
return nil, fmt.Errorf("read %s: attachment metadata has invalid size %d", req.Params.URI, metadata.Size)
}
if metadata.Size > attachmentResourceMaxBytes {
return nil, fmt.Errorf("read %s: attachment size %d exceeds %d-byte resource limit",
req.Params.URI, metadata.Size, attachmentResourceMaxBytes)
}
downloadArgs := []string{
"attachment", "download", id, "-",
"--workspace", ws,
"--variant", attachmentResourceVariant,
}
body, err := r.binaryFetcher.FetchBytes(ctx, append(downloadArgs, r.rootArgs...))
if err != nil {
return nil, fmt.Errorf("read %s bytes: %w", req.Params.URI, err)
}
if len(body) > attachmentResourceMaxBytes {
return nil, fmt.Errorf("read %s: downloaded attachment size %d exceeds %d-byte resource limit",
req.Params.URI, len(body), attachmentResourceMaxBytes)
}
// Label the blob from the bytes we actually return, not the separate
// `show` metadata call. Thumbnail generation is async, so in the window
// before the thumb-md row exists `show` can describe the original
// (e.g. image/gif) while `download` returns a freshly generated JPEG
// thumbnail — sniffing keeps the MIME label consistent with the
// payload. Fall back to the metadata MIME when the sniff is unsure.
mimeType := metadata.MIME
if sniffed := http.DetectContentType(body); strings.HasPrefix(sniffed, "image/") {
mimeType = sniffed
}
return []mcp.ResourceContents{
mcp.BlobResourceContents{
URI: req.Params.URI,
MIMEType: mimeType,
Blob: base64.StdEncoding.EncodeToString(body),
},
}, nil
}
// readBootstrap handles pad://workspace/{ws}/bootstrap. Shells out to
// `pad bootstrap --workspace <ws> --format json` so the resource stays
// in lockstep with the CLI and HTTP surfaces — one canonical builder
// (Server.BuildAgentBootstrap) feeds all three. Hosts that prefetch
// resources at session start get the full context in a single read.
func (r *resources) readBootstrap(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
ws, kind, arg, err := parsePadURI(req.Params.URI)
if err != nil {
return nil, err
}
if kind != resourceKindBootstrp || arg != "" {
return nil, fmt.Errorf("resource %q is not the bootstrap URI", req.Params.URI)
}
return r.fetchAsResource(ctx, req.Params.URI, jsonMIMEType,
[]string{"bootstrap", "--workspace", ws, "--format", "json"})
}
// fetchAsResource is the shared shell-out + wrap path. padArgs is
// the leading argument list (without root flags); rootArgs are
// appended so --url etc. survive into every dispatched call.
func (r *resources) fetchAsResource(
ctx context.Context,
uri, mimeType string,
padArgs []string,
) ([]mcp.ResourceContents, error) {
full := append(append([]string{}, padArgs...), r.rootArgs...)
out, err := r.fetcher.Fetch(ctx, full)
if err != nil {
return nil, fmt.Errorf("read %s: %w", uri, err)
}
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: uri,
MIMEType: mimeType,
Text: out,
},
}, nil
}
// parsePadURI extracts (workspace, kind, arg) from a pad:// URI.
// arg is empty for list-style resources (no trailing segment).
//
// Forms accepted:
//
// pad://workspace/{ws}/items
// pad://workspace/{ws}/items/{ref}
// pad://workspace/{ws}/dashboard
// pad://workspace/{ws}/collections
//
// Returns an error for malformed URIs (missing prefix, missing
// workspace, missing kind). Callers downstream still validate that
// kind matches the handler's expected resource type.
func parsePadURI(uri string) (workspace, kind, arg string, err error) {
if !strings.HasPrefix(uri, uriPrefixWorkspace) {
return "", "", "", fmt.Errorf("not a pad workspace URI: %q", uri)
}
rest := strings.TrimPrefix(uri, uriPrefixWorkspace)
if rest == "" {
return "", "", "", fmt.Errorf("missing workspace segment: %q", uri)
}
parts := strings.SplitN(rest, "/", 3)
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return "", "", "", fmt.Errorf("malformed pad URI: %q", uri)
}
workspace = parts[0]
kind = parts[1]
if len(parts) == 3 {
arg = parts[2]
}
return workspace, kind, arg, nil
}
// rootFlagsToArgs converts a startup root-flags map into the
// pre-formatted CLI token list every fetch should append. Empty
// values are skipped (matching BuildCLIArgs in dispatch.go).
func rootFlagsToArgs(rootFlags map[string]string) []string {
if len(rootFlags) == 0 {
return nil
}
// Sorted for deterministic test output.
names := make([]string, 0, len(rootFlags))
for n := range rootFlags {
names = append(names, n)
}
// Keep a stable order without pulling in sort just for this.
for i := 1; i < len(names); i++ {
for j := i; j > 0 && names[j] < names[j-1]; j-- {
names[j], names[j-1] = names[j-1], names[j]
}
}
var out []string
for _, n := range names {
v := rootFlags[n]
if v == "" {
continue
}
out = append(out, "--"+n, v)
}
return out
}