mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
475a70b57a
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
638 lines
22 KiB
Go
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
|
|
}
|