feat(mcp): add structured-only results

This commit is contained in:
Matt Faltyn
2026-09-11 12:15:29 +02:00
committed by xarmian
parent 07c00e008e
commit 6b9eafde2c
7 changed files with 176 additions and 25 deletions
+13
View File
@@ -387,6 +387,19 @@ pad mcp install claude-desktop # or: cursor, windsurf, claude-code, codex, --a
# Restart the client; pad shows up as the "pad" MCP server.
```
Cursor and Codex installations can opt into compact tool results:
```bash
pad mcp install cursor --structured-only
pad mcp install codex --structured-only
```
This keeps successful `structuredContent` and sends an empty required `content`
array instead of repeating the same JSON as text. Errors and text-only results
still carry text. Leave the flag off for clients that only read compatibility
fallbacks; MCP recommends the duplicate for backward compatibility but does not
require it.
`pad mcp install` writes each client's native config: JSON `mcpServers` for
Claude Desktop / Cursor / Windsurf, a **project-local `.mcp.json`** in the current
directory for `claude-code`, and an `[mcp_servers.pad]` table in
+11 -2
View File
@@ -47,6 +47,7 @@ See https://getpad.dev/mcp/local for client configuration.`,
// for every supported agent (creating config dirs as needed).
func mcpInstallCmd() *cobra.Command {
var allFlag bool
var structuredOnly bool
cmd := &cobra.Command{
Use: "install [agent]",
Short: "Install pad as an MCP server for a client app",
@@ -88,7 +89,7 @@ are preserved — only the "pad" entry is touched.`,
if err != nil || binary == "" {
binary = os.Args[0]
}
inst := &mcpserver.Installer{Binary: binary}
inst := &mcpserver.Installer{Binary: binary, StructuredOnly: structuredOnly}
switch {
case allFlag:
return runMCPInstallAll(cmd, inst)
@@ -100,6 +101,7 @@ are preserved — only the "pad" entry is touched.`,
},
}
cmd.Flags().BoolVar(&allFlag, "all", false, "install for every supported agent")
cmd.Flags().BoolVar(&structuredOnly, "structured-only", false, "omit duplicate structured-result JSON text")
return cmd
}
@@ -186,7 +188,11 @@ func runMCPInstallOne(cmd *cobra.Command, inst *mcpserver.Installer, agent strin
}
w := cmd.OutOrStdout()
if modified {
fmt.Fprintf(w, "Installed pad MCP entry for %s\n config: %s\n command: %s mcp serve\n", agent, path, inst.Binary)
serveArgs := "mcp serve"
if inst.StructuredOnly {
serveArgs += " --structured-only"
}
fmt.Fprintf(w, "Installed pad MCP entry for %s\n config: %s\n command: %s %s\n", agent, path, inst.Binary, serveArgs)
fmt.Fprintln(w, " → Restart the client to pick up the new server entry.")
} else {
fmt.Fprintf(w, "%s already up to date\n config: %s\n", agent, path)
@@ -225,6 +231,7 @@ func runMCPInstallAll(cmd *cobra.Command, inst *mcpserver.Installer) error {
// drive them directly.
func mcpServeCmd() *cobra.Command {
var debug bool
var structuredOnly bool
cmd := &cobra.Command{
Use: "serve",
Short: "Run the MCP server over stdio",
@@ -321,6 +328,7 @@ Shuts down cleanly on EOF, SIGINT, or SIGTERM.`,
RootFlags: rootFlags,
PadVersion: fullVersion(),
BootstrapFetcher: bootstrapFetcher,
StructuredOnly: structuredOnly,
}); err != nil {
return fmt.Errorf("pad mcp serve: register tools: %w", err)
}
@@ -354,5 +362,6 @@ Shuts down cleanly on EOF, SIGINT, or SIGTERM.`,
},
}
cmd.Flags().BoolVar(&debug, "debug", false, "verbose logging on stderr (development)")
cmd.Flags().BoolVar(&structuredOnly, "structured-only", false, "omit duplicate structured-result JSON text")
return cmd
}
+32 -12
View File
@@ -134,6 +134,10 @@ type ActionEnv struct {
// Catalog is the live catalog slice — supplied so pad_meta.action:
// tool-surface can introspect the catalog without an import cycle.
Catalog []ToolDef
// StructuredOnly removes duplicated text fallbacks from successful
// structured results. Opt-in because older MCP clients may only read text.
StructuredOnly bool
}
// Dispatch is the helper most fan-out actions use. Looks up cmdInfo
@@ -198,11 +202,12 @@ func appendToCatalog(def ToolDef) {
// CatalogOptions configures RegisterCatalog. Mirrors RegistryOptions
// for the cmdhelp-walk path but adds PadVersion (needed by pad_meta).
type CatalogOptions struct {
Doc *cmdhelp.Document
Workspace *WorkspaceState
Dispatcher Dispatcher
RootFlags map[string]string
PadVersion string
Doc *cmdhelp.Document
Workspace *WorkspaceState
Dispatcher Dispatcher
RootFlags map[string]string
PadVersion string
StructuredOnly bool
}
// RegisterCatalog installs the v0.2 catalog tools on srv. Returns the
@@ -217,12 +222,13 @@ func RegisterCatalog(srv *server.MCPServer, opts CatalogOptions) (int, error) {
return 0, err
}
env := ActionEnv{
Doc: opts.Doc,
Workspace: opts.Workspace,
Dispatcher: opts.Dispatcher,
RootFlags: opts.RootFlags,
PadVersion: opts.PadVersion,
Catalog: Catalog,
Doc: opts.Doc,
Workspace: opts.Workspace,
Dispatcher: opts.Dispatcher,
RootFlags: opts.RootFlags,
PadVersion: opts.PadVersion,
Catalog: Catalog,
StructuredOnly: opts.StructuredOnly,
}
count := 0
for _, def := range Catalog {
@@ -424,10 +430,24 @@ func makeFanOutHandler(def ToolDef, env ActionEnv) server.ToolHandlerFunc {
}
stripped[k] = v
}
return handler(ctx, stripped, env)
result, err := handler(ctx, stripped, env)
return applyStructuredOnly(result, err, env.StructuredOnly)
}
}
// applyStructuredOnly keeps MCP's required content array but removes the
// serialized JSON fallback when a modern client has explicitly opted into
// structuredContent. Errors and text-only results retain their text so an
// agent can still diagnose and recover from a failed call.
func applyStructuredOnly(result *mcp.CallToolResult, err error, enabled bool) (*mcp.CallToolResult, error) {
if !enabled || err != nil || result == nil || result.IsError || result.StructuredContent == nil {
return result, err
}
trimmed := *result
trimmed.Content = []mcp.Content{}
return &trimmed, nil
}
// compatAcceptedInputKeys lists input keys that are ACCEPTED without
// being schema-declared — documented compatibility forms that predate
// strict input validation (#1066 / ToolSurfaceVersion 0.24). Additions
+46
View File
@@ -151,6 +151,52 @@ func TestMakeFanOutHandler_DispatchesPerAction(t *testing.T) {
}
}
func TestMakeFanOutHandler_StructuredOnlyRemovesDuplicateSuccessText(t *testing.T) {
payload := map[string]any{"body": strings.Repeat("work context ", 400)}
raw, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
def := ToolDef{
Name: "pad_test",
Actions: map[string]ActionFn{
"get": func(_ context.Context, _ map[string]any, _ ActionEnv) (*mcp.CallToolResult, error) {
return mcp.NewToolResultStructured(payload, string(raw)), nil
},
},
}
req := callToolRequest(map[string]any{"action": "get"})
legacy, err := makeFanOutHandler(def, ActionEnv{})(context.Background(), req)
if err != nil {
t.Fatal(err)
}
modern, err := makeFanOutHandler(def, ActionEnv{StructuredOnly: true})(context.Background(), req)
if err != nil {
t.Fatal(err)
}
if len(legacy.Content) == 0 {
t.Fatal("default mode must retain the compatibility text fallback")
}
if modern.Content == nil || len(modern.Content) != 0 {
t.Fatalf("structured-only content = %#v, want non-nil empty array", modern.Content)
}
if modern.StructuredContent == nil {
t.Fatal("structured-only mode dropped structuredContent")
}
legacyJSON, _ := json.Marshal(legacy)
modernJSON, _ := json.Marshal(modern)
t.Logf("representative structured result: %d bytes default, %d bytes structured-only", len(legacyJSON), len(modernJSON))
if len(modernJSON)*3 >= len(legacyJSON)*2 {
t.Errorf("structured-only mode should remove roughly half the duplicated payload: default=%d modern=%d", len(legacyJSON), len(modernJSON))
}
errorResult := NewErrorResult(ErrorPayload{Code: ErrValidationFailed, Message: "fix the input"})
kept, err := applyStructuredOnly(errorResult, nil, true)
if err != nil || len(kept.Content) == 0 {
t.Fatal("structured errors must retain their diagnostic text fallback")
}
}
// TestMakeFanOutHandler_MissingAction returns a structured error
// listing valid actions so the agent can self-correct without a
// human round-trip. Wire-level guarantee: IsError = true, message
+29 -6
View File
@@ -209,6 +209,10 @@ func codexPathFor(home, _ string) (string, error) {
// Returns (modified=true, nil) when the on-disk content actually
// changed, (false, nil) when the file was already up to date.
func AddPadEntry(path, binary string) (bool, error) {
return addPadEntryJSON(path, binary, []string{"mcp", "serve"})
}
func addPadEntryJSON(path, binary string, args []string) (bool, error) {
if binary == "" {
return false, errors.New("AddPadEntry: binary path is required")
}
@@ -218,7 +222,7 @@ func AddPadEntry(path, binary string) (bool, error) {
}
wantedEntry := map[string]any{
"command": binary,
"args": []any{"mcp", "serve"},
"args": stringsToAny(args),
}
servers, _ := cfg["mcpServers"].(map[string]any)
if servers == nil {
@@ -403,6 +407,10 @@ func jsonEqual(a, b map[string]any) bool {
// unrelated keys and other mcp_servers entries are preserved. Returns
// (modified=true, nil) only when the on-disk content actually changed.
func addPadEntryTOML(path, binary string) (bool, error) {
return addPadEntryTOMLWithArgs(path, binary, []string{"mcp", "serve"})
}
func addPadEntryTOMLWithArgs(path, binary string, args []string) (bool, error) {
if binary == "" {
return false, errors.New("addPadEntryTOML: binary path is required")
}
@@ -412,7 +420,7 @@ func addPadEntryTOML(path, binary string) (bool, error) {
}
wantedEntry := map[string]any{
"command": binary,
"args": []any{"mcp", "serve"},
"args": stringsToAny(args),
}
// If mcp_servers exists but isn't a table (e.g. a scalar left by a
// hand-edit or an incompatible tool), refuse rather than silently
@@ -543,11 +551,19 @@ func writeTOMLConfig(path string, cfg map[string]any) error {
// read and written in its native format. JSON is the default; TOML is
// Codex-only for now.
func addEntry(agent *Agent, path, binary string) (bool, error) {
func addEntry(agent *Agent, path, binary string, args []string) (bool, error) {
if agent.Format == formatTOML {
return addPadEntryTOML(path, binary)
return addPadEntryTOMLWithArgs(path, binary, args)
}
return AddPadEntry(path, binary)
return addPadEntryJSON(path, binary, args)
}
func stringsToAny(values []string) []any {
out := make([]any, len(values))
for i, value := range values {
out[i] = value
}
return out
}
func removeEntry(agent *Agent, path string) (bool, error) {
@@ -572,6 +588,9 @@ func hasEntry(agent *Agent, path string) (bool, string, error) {
type Installer struct {
// Binary is the pad executable to register. Required for Install.
Binary string
// StructuredOnly opts installed clients into the token-efficient result
// mode by appending --structured-only to `pad mcp serve`.
StructuredOnly bool
// Home overrides os.UserHomeDir when non-empty (test-only).
Home string
// CWD overrides os.Getwd when non-empty (test-only). Used to resolve
@@ -648,7 +667,11 @@ func (i *Installer) Install(agentName string) (string, bool, error) {
if err != nil {
return "", false, err
}
modified, err := addEntry(agent, path, i.Binary)
args := []string{"mcp", "serve"}
if i.StructuredOnly {
args = append(args, "--structured-only")
}
modified, err := addEntry(agent, path, i.Binary, args)
return path, modified, err
}
+35
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
@@ -300,6 +301,40 @@ func TestInstaller_Install_RoundTripWithTempHome(t *testing.T) {
}
}
func TestInstaller_StructuredOnlyConfiguresCursorAndCodex(t *testing.T) {
for _, agent := range []string{"cursor", "codex"} {
t.Run(agent, func(t *testing.T) {
tmpHome := t.TempDir()
inst := &Installer{
Binary: "/usr/local/bin/pad",
Home: tmpHome,
GOOS: "linux",
StructuredOnly: true,
}
path, modified, err := inst.Install(agent)
if err != nil {
t.Fatalf("Install: %v", err)
}
if !modified {
t.Fatal("fresh install should modify config")
}
var args []any
if agent == "codex" {
cfg := readTOML(t, path)
args = cfg[codexServersKey].(map[string]any)[MCPServerKey].(map[string]any)["args"].([]any)
} else {
cfg := readConfig(t, path)
args = cfg["mcpServers"].(map[string]any)[MCPServerKey].(map[string]any)["args"].([]any)
}
want := []any{"mcp", "serve", "--structured-only"}
if !reflect.DeepEqual(args, want) {
t.Errorf("args = %#v, want %#v", args, want)
}
})
}
}
func TestInstaller_Install_MissingBinaryErrors(t *testing.T) {
inst := &Installer{Home: t.TempDir(), GOOS: "linux"}
if _, _, err := inst.Install("cursor"); err == nil {
+10 -5
View File
@@ -54,6 +54,10 @@ type RegistryOptions struct {
// fetch the blob separately via pad_meta.action=bootstrap or
// pad://workspace/{ws}/bootstrap. PLAN-1377 / TASK-1380.
BootstrapFetcher BootstrapFetcher
// StructuredOnly removes text copies of successful structured tool
// results. It is opt-in for clients that consume structuredContent.
StructuredOnly bool
}
// Register installs pad's MCP tools on srv: the built-in
@@ -80,11 +84,12 @@ func Register(srv *server.MCPServer, opts RegistryOptions) (int, error) {
count := 1 // pad_set_workspace
catalogCount, err := RegisterCatalog(srv, CatalogOptions{
Doc: opts.Doc,
Workspace: opts.Workspace,
Dispatcher: opts.Dispatcher,
RootFlags: opts.RootFlags,
PadVersion: opts.PadVersion,
Doc: opts.Doc,
Workspace: opts.Workspace,
Dispatcher: opts.Dispatcher,
RootFlags: opts.RootFlags,
PadVersion: opts.PadVersion,
StructuredOnly: opts.StructuredOnly,
})
if err != nil {
return 0, fmt.Errorf("register catalog: %w", err)