Repository navigation
feat(server): save_to_file — write full tool output to a whitelisted file (spec 076) - #1073
Draft
electrolobzik wants to merge 2 commits into
Draft
electrolobzik wants to merge 2 commits into
electrolobzik wants to merge 2 commits into
Conversation
…file (spec 076) Adds an optional `save_to_file` (+ `save_format`, `save_overwrite`) argument to call_tool_read/write/destructive. When set, the full untruncated upstream response is written to the given absolute path and the call returns a small JSON envelope (saved_to, bytes, sha256, format, content_blocks, non_text_blocks, preview, truncated_preview) instead of the body, so large responses no longer force raising tool_response_limit globally. Security model (internal/outputfile): paths must resolve inside one of the new `tool_output_roots` config entries; `Resolve` matches the whitelist after EvalSymlinks on the deepest existing ancestor, creates the root 0700 if missing, opens a single Go 1.25 os.Root handle and identity-checks it; `Write` uses only that handle (MkdirAll / O_EXCL 0600 temp / Sync / Rename), so intermediate-symlink swaps after validation are refused and the write can never be redirected outside the directory Resolve validated. Size capped by `tool_output_max_bytes` (default 50 MiB), no overwrite unless asked, temp files cleaned on every error path. Redaction runs before the save; failed saves still reach the tool-call record and activity log with status=error and are never silently forwarded inline. Argument validation runs before upstream dispatch (types, save_format enum, empty save_to_file, save_format/save_overwrite without save_to_file) so a destructive tool is never executed only to discard its result. CLI: `mcpproxy call tool --save-to-file/--save-format/--save-overwrite`. Config: `tool_output_roots` (JSON file only; deliberately no Settings-UI field) and `tool_output_max_bytes`, both hot-reloaded. Docs in docs/configuration.md; spec in specs/076-tool-output-save-to-file/. Known gaps: oas/docs.go was hand-mirrored (make swagger-verify not run) and the frontend bundle was not rebuilt.
Bring spec 076 (save_to_file) up to date with upstream main (9894081). Conflict resolutions: - cmd/mcpproxy/call_cmd.go: save flags moved onto the tool-read / tool-write / tool-destructive subcommands. - internal/server/mcp.go, content_forward.go: the save branch now sits before TOON encoding, uses the smart-mcp-proxy#935 activity status and Spec 116 recordToolCallAt; the token recount is TOON-aware. - oas/docs.go, oas/swagger.yaml: regenerated with swag v2.0.0-rc4. - docs/configuration/config-file.md: merged both sections. Follow-ups needed by upstream changes: - callToolMetaKeys includes the save_* keys (smart-mcp-proxy#1317 flattening). - ToolOutputMaxBytes added to the numeric omitempty allowlist (smart-mcp-proxy#1175). - The Spec 085 menu-surface test pins the save_* parameters. - The EmbeddedResource round-trip test was updated for mcp-go v1.0.0. Security hardening from review: - Path pre-flight (disabled, outside roots, existing file) runs before dispatch, with auditAuthz deny and a blocked policy decision. - Output validation (Spec 056) runs before saving; the preview envelope is spotlighted (Spec 054). - Overlapping or aliased tool_output_roots are a config load error and fail closed in Resolve, including aliases reached through symlink hops (manual physical-path walk). - Unix: the root and every symlink on its path must be owned by the proxy user (or root for symlinks); the root must not be world-writable. - UNC and device volumes (including \??\ forms) are refused unless a root lives on that exact volume. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds an optional
save_to_fileargument tocall_tool_read,call_tool_writeandcall_tool_destructive, together withsave_formatandsave_overwrite. When it is set, the full, untruncated upstream response is written to the given absolute path. The call then returns a small JSON envelope instead of the body:{"saved_to": "...", "bytes": 123456, "sha256": "...", "format": "text|json", "content_blocks": 3, "non_text_blocks": 0, "preview": "...", "truncated_preview": true}Motivation: agents sometimes need a large upstream payload, such as a file tree, design metadata or a long log. Today they must either raise
tool_response_limitglobally or request the same payload again in slices. Withsave_to_file, the agent gets the whole response on disk once and can grep or page it locally.Security model (
internal/outputfile)Where files can go
tool_output_rootsconfig entries. These are absolute paths, set in the JSON config only; there is deliberately no Settings-UI field.Resolvealso fails closed at call time if two roots alias each other on disk. It compares the literal, resolved and parent-resolved forms of each root. It also walks each root's path one component at a time and follows every symlink hop by hand, so a root reached through another root is refused, even when a planted symlink would make the fully resolved forms disjoint.\\host\share,\\?\,\??\,GLOBALROOT) are refused before any filesystem call, unless a configured root is on that exact volume.How the root is opened
Resolvematches the target against the whitelist after runningEvalSymlinkson its deepest existing ancestor.0700if the root is missing.os.Roothandle and checks that handle's identity against both the resolved root and the configured path.How the file is written
Writeuses only that handle:MkdirAll, anO_EXCL0600temp file,Sync, thenRename. A symlink swapped into an intermediate directory after validation is refused, so the write can never land outside the directory thatResolvevalidated.tool_output_max_bytes(default 50 MiB).save_overwrite: trueis set.What is saved and returned
status=error. It is never silently forwarded inline.Checks before dispatch
These are all rejected before the upstream call, so a destructive tool never runs only to have its result thrown away:
save_formatenum;save_to_file;save_formatorsave_overwritegiven withoutsave_to_file;save_overwrite.Denials are audited (
auditAuthz deny) and emitted as ablockedpolicy decision.Other changes
mcpproxy call tool-read|tool-write|tool-destructive --save-to-file / --save-format / --save-overwrite, with the same guard against orphan flags.tool_output_rootsandtool_output_max_bytes, both hot-reloaded. They are documented indocs/configuration.md,docs/configuration/config-file.mdanddocs/setup.md.specs/076-tool-output-save-to-file/spec.md.recountSaveOrTruncateTokenMetrics, which is TOON-aware), so the metrics report what was actually sent to the client.save_*keys are added tocallToolMetaKeys, so Chrome DevTools MCP --autoConnect: tool calls hang indefinitely through MCPProxy stdio transport #1317's argument flattening never forwards them upstream.ToolOutputMaxBytesis added to the numeric-omitempty allowlist (omitempty silently erases explicitly-configured zero values on max_result_size_chars, activity_max_size_mb and tracing.sample_rate #1175).save_*parameters (all optional).Merge with current main
upstream/main(9894081) was merged in; the branch was not rebased. Conflicts were resolved incmd/mcpproxy/call_cmd.go,docs/configuration/config-file.md,internal/server/content_forward.go,internal/server/mcp.goandoas/docs.go.recordToolCallAt.oas/docs.gowas regenerated with swag v2.0.0-rc4. It is no longer hand-mirrored.Tests
go build ./...andgo build -tags server ./...pass.golangci-lintv2.9.0 reports 0 issues, both plain and with--build-tags server.GOOS=windows go vetpasses on the touched packages.internal/outputfile/overlap_test.go: alias, overlap and UNC/device volumes;ownership_unix_test.go;internal/server/save_to_file_test.gothat assert the upstream is not called;main: the tokenizer-dependent tests (TestRuntime_CalculateTokenSavings_Wiring,TestDescribeTool_DefinitionTokenBudget,TestDeferredDirect_TokenReduction_*). Fork CI has not run yet.Known limitations
save_overwrite=false, can race. The lastRenamewins.saved_to.frontend/src/views/settings/fields.tsonly gains two read-only field descriptions.Still a draft; happy to split or rework any part of this.
🤖 Generated with Claude Code