feat(server): save_to_file — write full tool output to a whitelisted file (spec 076) - #1073
Draft
electrolobzik wants to merge 1 commit into
Draft
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.
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_file(+save_format,save_overwrite) argument tocall_tool_read/call_tool_write/call_tool_destructive. When set, the full, untruncated upstream response is written to the given absolute path and the call 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 that need a large upstream payload (file trees, design metadata, long logs) currently have to raise
tool_response_limitglobally or re-request the same payload in slices. Withsave_to_filethe agent gets the whole response on disk once and greps/pages it locally.Security model (
internal/outputfile)tool_output_rootsconfig entries (absolute paths; JSON config only, deliberately no Settings-UI field).Resolvematches the whitelist afterEvalSymlinkson the deepest existing ancestor, creates the root0700if missing, opens a single Go 1.25os.Roothandle and identity-checks it.Writeuses only that handle (MkdirAll/O_EXCL0600temp file /Sync/Rename), so intermediate-symlink swaps after validation are refused and the write can never be redirected outside the directoryResolvevalidated.tool_output_max_bytes(default 50 MiB); no overwrite unlesssave_overwrite: true; temp files cleaned on every error path.status=errorand is never silently forwarded inline.Argument validation before dispatch
Types, the
save_formatenum, an emptysave_to_file, andsave_format/save_overwritegiven withoutsave_to_fileare all rejected before the upstream call, so a destructive tool is never executed only to have its result discarded.Other changes
mcpproxy call tool --save-to-file / --save-format / --save-overwrite(with the same orphan-flag guard).tool_output_roots,tool_output_max_bytes— both hot-reloaded; documented indocs/configuration.md,docs/configuration/config-file.md,docs/setup.md.specs/076-tool-output-save-to-file/spec.md.recountSaveOrTruncateTokenMetrics) so the truncation/saving path reports what was actually sent to the client.Tests
go build ./...,go vet ./...,gofmt -lclean,go test ./...green (all foreground). New coverage ininternal/outputfile/outputfile_test.go(path resolution, symlink swaps, size cap, overwrite semantics, temp-file cleanup),internal/server/save_to_file_test.go(envelope, pre-dispatch validation, redaction-before-save, error paths),internal/config/validation_test.go,internal/runtime/config_hotreload_test.go.Known gaps / please check
oas/docs.gowas hand-mirrored fromoas/swagger.yaml;make swagger-verifywas not run locally — CI may flag it and I will regenerate.frontend/src/views/settings/fields.tsonly gains the two read-only field descriptions).os.Rootopen where an ancestor swap is detected and refused rather than raced.Opened as a draft so CI can run first; happy to split or rework any part of this.