Repository navigation
Conversation
makeDirectModeHandler forwarded upstream results without calling applyOutputSanitisation or spotlightForwarded, so redact, block, strip_control_chars and spotlight_untrusted never applied to /mcp/all. Call both from the direct handler the same way the call_tool_* handlers do: sanitise the raw result before the activity classification and truncation, and spotlight after truncation, refreshing the recorded response text. Fixes smart-mcp-proxy#1683
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Direct mode (/mcp/all, server__tool) now runs redact/block/strip/spotlight (smart-mcp-proxy#1683), so drop it from the not-covered list.
|
Thank you, @james-crowley! Great find, and a careful fix. The direct handler now runs sanitisation in exactly the same order as On the docs question: the page lives in this repo ( One note for later, not a blocker: on a |
… block Direct routing (#1685) returned applyOutputSanitisation's block result before emitting the call's completion. It now emits the same error tool_call (error_class proxy_policy, block message only) as the call_tool_* paths. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ks a response (#1687) ## Summary When `output_sanitisation` blocks a response (`response_action: block`), the call_tool_* handlers returned before recording the call's completion. The upstream call **did** run (side effects may have happened), yet only a `policy_decision` was persisted: no `tool_call` activity row (started events are not persisted), an SSE `started` event with no completion, and no tool-call history entry. `handleCallToolVariant`, the legacy `handleCallTool` and direct routing mode (`makeDirectModeHandler`, added by #1685) now close the call on a block: - **Tool-call history:** the attempt is stored with `Error` set to the block message and `Response` cleared, so the blocked payload is never stored. - **Activity:** a `tool_call` completion is emitted with status `error`, `error_class: proxy_policy` and `fault_domain: proxy`. It carries the fixed block message, never the response. On call_tool_* paths the `internal_tool_call` record is emitted as well. - The new helper `recordOutputBlocked` follows the convention of the code_execution sub-call block path, which already closed its record this way. ## Counting / audit check - **Spec 107 audit:** the block's `policy_decision` already writes `tool_call outcome:blocked reason:output_sanitisation` (`isPostDispatchBlockKey`). The completion's audit write is the existing deduped no-op (`toolCallWritten`), so the audit log still has one line per attempt. - **Telemetry:** nothing is double-counted. The availability block counter comes only from `policy_decision`, the upstream-attempt bucket was counted at dispatch, and activation is not recorded. - **Activity/Usage call totals (`storage.CountsAsCall`):** the `policy_decision` (blocked) and the new `tool_call` (error) both count as failed calls. One blocked attempt therefore counts as two failed calls in those totals. The code_execution sanitisation block and the generation-changed refusal pair already counted this way. Separating them needs the post-dispatch block key on the `policy_decision` record, so that is left for a follow-up. ## Direct mode After #1685 merged, origin/main was merged into this branch and the direct-mode block now emits the same error `tool_call` completion. Direct mode keeps no tool-call history. ## Tests - `TestCallToolVariant_SanitisationBlockRecordsToolCallCompletion` and `TestLegacyCallTool_SanitisationBlockRecordsToolCallCompletion` drive a real in-process upstream that returns an AWS key with block mode on. Each asserts one persisted `tool_call` with status `error` and `error_class` `proxy_policy`, the `policy_decision` beside it, and the secret absent from every activity record. `TestDirectMode_SanitisationBlockRecordsToolCallCompletion` asserts the same for direct mode. All three failed before the fix (no `tool_call` record). - `TestRecordOutputBlocked_HistoryCarriesBlockMessageNotResponse` asserts the history entry carries the block message and no response. The direct-routing fixture's incarnation guard drops all history writes, so this is tested on the helper. - `go test -race ./internal/server/` with the unit-tests.yml `-skip` regex: pass. - `golangci-lint` v2 (`.github/.golangci.yml`), bare and `--build-tags server`, over `./internal/server/...`: no issues in changed files. Six existing staticcheck findings remain in untouched test files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Description
Fixes #1683.
output_sanitisation(redact, block, strip control characters, spotlight) never ran for direct routing mode (/mcp/all,server__toolnames). The direct handler (makeDirectModeHandlerinmcp_routing.go) forwarded the upstream result without callingapplyOutputSanitisationorspotlightForwarded, which thecall_tool_*handlers andcode_executionalready call.This adds both calls to the direct handler, mirroring
handleCallToolVariant:applyOutputSanitisationruns on the raw result before the activity classification and truncation. A non-nil result is a block and is returned as-is.spotlightForwardedruns after truncation, andresponseTextis refreshed withforwardedTextso the recorded activity matches what the agent receives.With no
output_sanitisationconfigured both calls are no-ops, so default behavior is byte-for-byte unchanged. Error results (isError: true) and non-text blocks keep the existing exemptions insideapplyOutputSanitisation.I did not touch the docs page that lists direct mode under "Scope and limits"; that is in the separate docs site and I wasn't sure of the process there. Happy to add that edit here if you want it.
Testing
Verification:
internal/server/direct_output_sanitisation_test.godrives the handler mcp-go registered (directRoutingFixture) against a real in-process upstream: redact, block, spotlight, strip, and the opt-out default. The first four fail onmainand pass with this change. I added a smallokBodyoverride tocountingUpstreaminscope_fixture_test.goso an upstream can return a chosen payload.go test ./internal/server/passes with the e2e tests skipped (-skip 'TestE2E|TestBinary|TestMCPProtocol|TestCodeExecClientModeE2E'), because those need a built binary andnpxin my environment. CI will cover them.output_sanitisation: {spotlight_untrusted, response_action: redact, strip_control_chars}:/mcpcall_tool_readreturned[REDACTED:cloud_credentials]wrapped in«untrusted:...», while/mcp/allreturned the raw key. This patch is meant to make the second match the first.