Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions cmd/mcpproxy/call_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ Examples:
// Intent flags for tool variant commands
callIntentReason string
callIntentSensitivity string

// save_to_file flags for tool variant commands (Spec 076)
callSaveToFile string
callSaveFormat string
callSaveOverwrite bool
)

// GetCallCommand returns the call command for adding to the root command
Expand Down Expand Up @@ -188,6 +193,11 @@ func setupToolVariantFlags(cmd *cobra.Command) {
cmd.Flags().StringVar(&callIntentReason, "reason", "", "Human-readable explanation for the operation (max 1000 chars)")
cmd.Flags().StringVar(&callIntentSensitivity, "sensitivity", "", "Data sensitivity classification: public, internal, private, unknown")

// save_to_file flags (Spec 076) — mirrors the call_tool_* MCP tool params
cmd.Flags().StringVar(&callSaveToFile, "save-to-file", "", "Absolute path under a configured tool_output_roots entry; writes the full untruncated response there instead of printing it")
cmd.Flags().StringVar(&callSaveFormat, "save-format", "", "Format for --save-to-file: text (default) or json")
cmd.Flags().BoolVar(&callSaveOverwrite, "save-overwrite", false, "Overwrite an existing file at --save-to-file instead of failing")

// Mark required flags
if err := cmd.MarkFlagRequired("tool-name"); err != nil {
panic(fmt.Sprintf("Failed to mark tool-name flag as required: %v", err))
Expand Down Expand Up @@ -362,6 +372,21 @@ func runCallToolVariant(toolVariant, operationType string) error {
if callIntentReason != "" {
variantArgs["intent_reason"] = callIntentReason
}
// Add flat save_to_file params (Spec 076). The server rejects
// save_format/save_overwrite without save_to_file too; failing here first
// gives the user the answer without a round trip.
if callSaveToFile == "" && (callSaveFormat != "" || callSaveOverwrite) {
return fmt.Errorf("--save-format/--save-overwrite require --save-to-file")
}
if callSaveToFile != "" {
variantArgs["save_to_file"] = callSaveToFile
}
if callSaveFormat != "" {
variantArgs["save_format"] = callSaveFormat
}
if callSaveOverwrite {
variantArgs["save_overwrite"] = callSaveOverwrite
}

// Load configuration
globalConfig, err := loadCallConfig()
Expand All @@ -385,6 +410,9 @@ func runCallToolVariant(toolVariant, operationType string) error {
if callIntentReason != "" {
fmt.Printf(" Reason: %s\n", callIntentReason)
}
if callSaveToFile != "" {
fmt.Printf(" Save to file: %s\n", callSaveToFile)
}
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n")

// Detect daemon and use client mode if available
Expand Down
82 changes: 82 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,86 @@ MCPProxy looks for configuration in these locations (in order):
| `tool_response_limit` | integer | `20000` | Maximum characters in tool responses (0 = unlimited) |
| `call_tool_timeout` | string | `"2m"` | Timeout for tool calls (e.g., `"30s"`, `"2m"`, `"5m"`). **Note**: When using agents like Codex or Claude as MCP servers, you may need to increase this timeout significantly, even up to 10 minutes (`"10m"`), as these agents may require longer processing times for complex operations |

### Save Tool Output to File

`call_tool_read` / `call_tool_write` / `call_tool_destructive` accept an
optional `save_to_file` parameter: instead of returning the (possibly
`tool_response_limit`-truncated) response body, mcpproxy writes the **full,
untruncated** upstream response to a file and returns a short JSON envelope
(`saved_to`, `bytes`, `sha256`, `format`, `content_blocks`, `non_text_blocks`,
`preview`, `truncated_preview`) instead. This is disabled by default —
`save_to_file` requests are rejected until `tool_output_roots` is configured.

```json
{
"tool_output_roots": ["/Users/me/mcpproxy-out"],
"tool_output_max_bytes": 52428800
}
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `tool_output_roots` | array of strings | `[]` (disabled) | Absolute directory paths `save_to_file` is allowed to write under. Each path must be absolute — a relative entry fails config validation. Symlinked roots are resolved once at request time, so pointing a root at (e.g.) a symlinked temp directory works as expected. Configured in the JSON config file only — there is no Settings-UI field for it, since the UI's free-text controls bind a plain string with no array coercion and would corrupt the list on save. The matched root directory (and any missing ancestor directories) is created `0700` on first use as soon as a request matches it, even if that request is ultimately rejected for an unrelated reason (e.g. the response is too large) — a rejected request can still leave an empty root directory behind. |
| `tool_output_max_bytes` | integer | `52428800` (50 MiB) | Maximum size, in bytes, of a single `save_to_file` write. A request that would exceed this is rejected and no file is left behind. |

**Security notes:**
- Every `save_to_file` path is resolved against `tool_output_roots` with
symlink-safe, separator-bounded prefix matching — `/root/proj` cannot be
satisfied by a sibling path like `/root/proj-evil`, and a symlink that
would walk the resolved path back outside every configured root is
rejected (`ErrOutsideRoots`), as is a symlink sitting at the final target
path itself (`ErrInvalidPath`). The write itself is then confined to the
matched root via a single `*os.Root` handle opened once, immediately after
the root is resolved — every filesystem operation the write performs
(creating parent directories, writing the temp file, renaming into place)
goes through that one handle, never through a path string again. This
closes a symlink or rename planted **inside** the root between the
resolve step and the write, and any replacement of the root directory (or
its ancestors) **after** the handle is open — once the handle is open, a
later swap of the root's own path cannot move it. It does **not** close a
race that replaces an **ancestor** of a configured root in the
microseconds between mcpproxy resolving that ancestor's symlinks and
opening the root — ancestors of a configured root are admin-controlled,
and a same-user process able to win that race could already write
anywhere the mcpproxy process can.
- Directory components under a root must be real directories — if any path
component `save_to_file` would need to create or traverse is itself a
symlink, the write fails (surfaced as a plain filesystem error, e.g.
`mkdirat sub: file exists`) rather than following it. This is a byproduct
of the same `os.Root`-based confinement, not a separate check.
- Root matching is **case-sensitive**, independent of the underlying
filesystem's own case sensitivity — on a case-insensitive filesystem (e.g.
default macOS/Windows volumes), configure `tool_output_roots` using the
same casing your `save_to_file` requests will use, since `/Root/proj` and
`/root/proj` are treated as different, non-matching prefixes even though
the filesystem itself would resolve them to the same directory.
- Saved files are written `0600` (owner read/write only) and any directories
`save_to_file` creates under a root are `0700` (owner-only).
- Writes are atomic (temp file in the destination directory, `fsync`d, then
renamed into place) and default to **not overwriting** an existing file —
pass `"save_overwrite": true` explicitly to replace one.
- A `save_to_file` request against an upstream response that itself came
back as an error is never honored — errors are always returned inline so
they stay visible to the caller.
- A save failure that isn't caught before dispatch — an outside-roots
path, an existing file without `save_overwrite`, or a response over
`tool_output_max_bytes` — occurs **after** the upstream tool has already
executed, and by design the response body is then not returned inline as
a fallback; the caller explicitly asked for a file, so the failure is
always reported as a tool error, never silently forwarded inline.
- The redaction pipeline (`applyOutputSanitisation`, secret-stripping) still
runs on the response **before** it is saved, so a saved file never contains
a secret that redaction would otherwise have stripped. Output-schema
validation (strict-mode blocking against a tool's declared output schema)
and response spotlighting do **not** run on a saved response at all — the
file on disk holds the full, un-spotlighted upstream text as redaction
left it. An agent that reads a saved file back must treat its contents as
untrusted data, the same as any other unvalidated tool output.
- Saving to file does not remove the response body from tool-call history —
the full upstream response is still persisted to the tool-call record
(BoltDB) on a saved call exactly as it is for any other call, so the
existing history retention/redaction rules apply unchanged.

### Discovery & Health Checks

mcpproxy keeps each upstream connection alive and its tool index fresh with two
Expand Down Expand Up @@ -982,6 +1062,8 @@ Here's a complete configuration example with all major sections:
"api_key": "",
"tools_limit": 15,
"tool_response_limit": 20000,
"tool_output_roots": [],
"tool_output_max_bytes": 52428800,
"call_tool_timeout": "2m",
"debug_search": false,
"enable_prompts": true,
Expand Down
4 changes: 4 additions & 0 deletions docs/configuration/config-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ MCPProxy uses a JSON configuration file located at `~/.mcpproxy/mcp_config.json`
"tool_discovery_interval": "5m",
"tools_limit": 15,
"tool_response_limit": 20000,
"tool_output_roots": [],
"tool_output_max_bytes": 52428800,
"enable_code_execution": false,
"code_execution_timeout_ms": 120000,
"code_execution_max_tool_calls": 0,
Expand Down Expand Up @@ -65,6 +67,8 @@ MCPProxy uses a JSON configuration file located at `~/.mcpproxy/mcp_config.json`
|--------|------|---------|-------------|
| `tools_limit` | integer | `15` | Maximum tools to return in a single request |
| `tool_response_limit` | integer | `20000` | Maximum characters in tool response |
| `tool_output_roots` | array of strings | `[]` (disabled) | Absolute directory paths the `save_to_file` param of `call_tool_read`/`call_tool_write`/`call_tool_destructive` may write under. Empty disables the feature. See [Save Tool Output to File](../configuration.md#save-tool-output-to-file). |
| `tool_output_max_bytes` | integer | `52428800` | Maximum bytes a single `save_to_file` write may produce. |

### Tool Discovery & Health Check Intervals

Expand Down
7 changes: 7 additions & 0 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,13 @@ tail -f ~/Library/Logs/mcpproxy/main.log | grep -E "(github-server|oauth|error)"
}
```

Need the *full* untruncated response instead of a bigger limit? `call_tool_read` /
`call_tool_write` / `call_tool_destructive` support a `save_to_file` parameter
that writes it straight to disk — see [Save Tool Output to File](configuration.md#save-tool-output-to-file).
When mcpproxy runs as a background daemon, `save_to_file` writes as whatever
user/filesystem context the daemon process itself runs under, not the CLI
caller — make sure `tool_output_roots` points somewhere that user can write.

### OAuth Configuration

For servers requiring authentication:
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/views/settings/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,14 @@ export const GENERAL_FIELDS: SettingField[] = [
},
{ key: 'tools_limit', label: 'Search results limit', help: 'How many tools a single tool-search returns to the agent.', control: 'number', min: 1, max: 1000 },
{ key: 'tool_response_limit', label: 'Max tool response size (characters)', help: 'Responses larger than this are truncated and cached so the agent can page through them. 0 = never truncate.', control: 'number', min: 0 },
// Spec 076 — save_to_file. tool_output_roots ([]string) is intentionally
// NOT exposed here: SettingField's free-text controls (including
// 'textarea') bind a plain string and emit a plain string on change, with
// no newline<->array coercion, so wiring it up here would silently
// corrupt the array on save. Configure tool_output_roots in the JSON
// config file directly (see docs/configuration.md) until a dedicated
// array-of-strings control exists.
{ key: 'tool_output_max_bytes', label: 'Max save_to_file write size (bytes)', help: 'Maximum size of a single save_to_file write. 0 = use the built-in default (50 MiB).', control: 'number', min: 0, docs: '/configuration#save-tool-output-to-file' },
{ key: 'call_tool_timeout', label: 'Tool call timeout', help: 'How long to wait for a single tool call before giving up. e.g. 2m, 90s, 30s.', control: 'duration', placeholder: '2m' },
{
key: 'logging.level',
Expand Down
79 changes: 79 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"

Expand All @@ -15,6 +16,10 @@ import (
const (
defaultPort = "127.0.0.1:8080" // Localhost-only binding by default for security

// defaultToolOutputMaxBytes is the save_to_file write-size cap applied
// when ToolOutputMaxBytes is unset (0). 50 MiB.
defaultToolOutputMaxBytes int64 = 52428800

// Routing mode constants (Spec 031)
RoutingModeRetrieveTools = "retrieve_tools" // Default: BM25 search via retrieve_tools + call_tool_read/write/destructive
RoutingModeDirect = "direct" // All upstream tools exposed directly with serverName__toolName naming
Expand Down Expand Up @@ -118,6 +123,21 @@ type Config struct {
CallToolTimeout Duration `json:"call_tool_timeout" mapstructure:"call-tool-timeout" swaggertype:"string"`
MaxResultSizeChars int `json:"max_result_size_chars,omitempty" mapstructure:"max-result-size-chars"` // Advertised on every tool as `_meta.anthropic/maxResultSizeChars`; raises Claude Code's inline-response ceiling from 50k to up to 500k chars. Set to 0 to disable.

// ToolOutputRoots is the whitelist of absolute directory prefixes the
// `save_to_file` parameter on call_tool_read/write/destructive is allowed
// to write under (Spec 076).
// Empty (the default) disables the feature entirely — call_tool_* returns
// a "save_to_file is disabled" error for any save_to_file request. Each
// entry must be an absolute path (validated in ValidateDetailed) and is
// stored filepath.Clean'ed; entries need not exist at startup — they are
// resolved (including symlinks) at the time of each save_to_file call, in
// internal/outputfile.
ToolOutputRoots []string `json:"tool_output_roots,omitempty" mapstructure:"tool-output-roots"`
// ToolOutputMaxBytes caps the size of a single save_to_file write. 0 (the
// default) means "use the built-in default" (50 MiB); negative values are
// a config error. See internal/outputfile.Write.
ToolOutputMaxBytes int64 `json:"tool_output_max_bytes,omitempty" mapstructure:"tool-output-max-bytes"`

// Discovery & health-check cadence (spec 074, #608). Both are *Duration
// tri-state pointers: nil = inherit the built-in default; a pointer to 0s =
// the loop is disabled; a positive value = that interval. Defaults live only
Expand Down Expand Up @@ -1139,6 +1159,11 @@ func DefaultConfig() *Config {
CallToolTimeout: Duration(2 * time.Minute), // Default 2 minutes for tool calls
MaxResultSizeChars: 500000, // Claude Code's inline-response hard max

// save_to_file (Spec 076): disabled by default (no whitelisted roots);
// when enabled via config, writes default-cap at 50 MiB per file.
ToolOutputRoots: nil,
ToolOutputMaxBytes: defaultToolOutputMaxBytes,

// Default secure environment configuration
Environment: secureenv.DefaultEnvConfig(),

Expand Down Expand Up @@ -1384,6 +1409,46 @@ func (c *Config) ValidateDetailed() []ValidationError {
})
}

// Validate ToolOutputRoots (save_to_file whitelist, Spec 076): every
// configured root must be an absolute path, and not the filesystem root
// itself. Roots are not required to exist yet.
//
// A root of "/" (or any OS's equivalent fixed point, e.g. "C:\" on
// Windows) would in principle whitelist the entire filesystem — but
// outputfile.Resolve's prefix-matching would in practice reject every
// requested path under it anyway, because Resolve requires
// strings.HasPrefix(target, root+separator), and a root of "/" cleans to
// exactly "/" with nothing after the separator to compare against ("/"+
// "/" doubles the separator, which no cleaned target path ever starts
// with). So "/" is not a privilege-escalation risk today, but it IS a
// silently-useless config value that matches nothing — reject it here
// with an explicit message rather than let the admin discover the dead
// entry only when every save_to_file call against it mysteriously fails.
for _, root := range c.ToolOutputRoots {
if !filepath.IsAbs(root) {
errors = append(errors, ValidationError{
Field: "tool_output_roots",
Message: fmt.Sprintf("root %q must be an absolute path", root),
})
continue
}
cleaned := filepath.Clean(root)
if filepath.Dir(cleaned) == cleaned {
errors = append(errors, ValidationError{
Field: "tool_output_roots",
Message: fmt.Sprintf("root %q must not be the filesystem root itself — it matches no save_to_file target", root),
})
}
}

// Validate ToolOutputMaxBytes
if c.ToolOutputMaxBytes < 0 {
errors = append(errors, ValidationError{
Field: "tool_output_max_bytes",
Message: "cannot be negative",
})
}

// Validate timeout
if c.CallToolTimeout.Duration() <= 0 {
errors = append(errors, ValidationError{
Expand Down Expand Up @@ -1602,6 +1667,20 @@ func (c *Config) Validate() error {
if c.CallToolTimeout.Duration() <= 0 {
c.CallToolTimeout = Duration(2 * time.Minute) // Default to 2 minutes
}
// save_to_file (Spec 076): 0 means "use the built-in default", NOT disabled
// (unlike ToolResponseLimit above) — the feature's on/off switch is
// ToolOutputRoots being empty, not this cap. Negative values are caught
// by ValidateDetailed below and never reach here as a successful Validate().
if c.ToolOutputMaxBytes == 0 {
c.ToolOutputMaxBytes = defaultToolOutputMaxBytes
}
if len(c.ToolOutputRoots) > 0 {
cleaned := make([]string, len(c.ToolOutputRoots))
for i, root := range c.ToolOutputRoots {
cleaned[i] = filepath.Clean(root)
}
c.ToolOutputRoots = cleaned
}
// Apply code execution defaults
if c.CodeExecutionTimeoutMs <= 0 {
c.CodeExecutionTimeoutMs = 120000 // 2 minutes (120,000ms)
Expand Down
Loading