Skip to content

feat(server): save_to_file — write full tool output to a whitelisted file (spec 076) - #1073

Draft
electrolobzik wants to merge 1 commit into
smart-mcp-proxy:mainfrom
HaloCollar:feat/tool-response-save-to-file
Draft

feat(server): save_to_file — write full tool output to a whitelisted file (spec 076)#1073
electrolobzik wants to merge 1 commit into
smart-mcp-proxy:mainfrom
HaloCollar:feat/tool-response-save-to-file

Conversation

@electrolobzik

Copy link
Copy Markdown
Contributor

Summary

Adds an optional save_to_file (+ save_format, save_overwrite) argument to call_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_limit globally or re-request the same payload in slices. With save_to_file the agent gets the whole response on disk once and greps/pages it locally.

Security model (internal/outputfile)

  • Paths must resolve inside one of the new tool_output_roots config entries (absolute paths; JSON config only, deliberately no Settings-UI field).
  • 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 file / 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 save_overwrite: true; temp files cleaned on every error path.
  • Redaction runs before the save. A failed save still reaches the tool-call record and activity log with status=error and is never silently forwarded inline.

Argument validation before dispatch

Types, the save_format enum, an empty save_to_file, and save_format / save_overwrite given without save_to_file are all rejected before the upstream call, so a destructive tool is never executed only to have its result discarded.

Other changes

  • CLI: mcpproxy call tool --save-to-file / --save-format / --save-overwrite (with the same orphan-flag guard).
  • Config: tool_output_roots, tool_output_max_bytes — both hot-reloaded; documented in docs/configuration.md, docs/configuration/config-file.md, docs/setup.md.
  • Spec: specs/076-tool-output-save-to-file/spec.md.
  • Token metrics are recounted on the envelope (recountSaveOrTruncateTokenMetrics) so the truncation/saving path reports what was actually sent to the client.

Tests

go build ./..., go vet ./..., gofmt -l clean, go test ./... green (all foreground). New coverage in internal/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.go was hand-mirrored from oas/swagger.yaml; make swagger-verify was not run locally — CI may flag it and I will regenerate.
  • The frontend bundle was not rebuilt (frontend/src/views/settings/fields.ts only gains the two read-only field descriptions).
  • Documented residuals: the root directory is created even when the request is later rejected; the saved body is still stored in the BoltDB tool-call history (existing behaviour, unchanged); there is a microsecond window between validation and the os.Root open 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.

…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant