Skip to content

Commit 4e02c36

Browse files
feat(server): apply output_sanitisation to direct routing mode (#1685)
## Description Fixes #1683. `output_sanitisation` (redact, block, strip control characters, spotlight) never ran for direct routing mode (`/mcp/all`, `server__tool` names). The direct handler (`makeDirectModeHandler` in `mcp_routing.go`) forwarded the upstream result without calling `applyOutputSanitisation` or `spotlightForwarded`, which the `call_tool_*` handlers and `code_execution` already call. This adds both calls to the direct handler, mirroring `handleCallToolVariant`: - `applyOutputSanitisation` runs on the raw result before the activity classification and truncation. A non-nil result is a block and is returned as-is. - `spotlightForwarded` runs after truncation, and `responseText` is refreshed with `forwardedText` so the recorded activity matches what the agent receives. With no `output_sanitisation` configured 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 inside `applyOutputSanitisation`. 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 - [x] I have tested these changes locally - [x] I have added/updated tests that prove my fix is effective or my feature works - [x] All existing tests pass (see note) Verification: - New `internal/server/direct_output_sanitisation_test.go` drives 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 on `main` and pass with this change. I added a small `okBody` override to `countingUpstream` in `scope_fixture_test.go` so 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 and `npx` in my environment. CI will cover them. - On a v0.71.1 gateway with `output_sanitisation: {spotlight_untrusted, response_action: redact, strip_control_chars}`: `/mcp` `call_tool_read` returned `[REDACTED:cloud_credentials]` wrapped in `«untrusted:...»`, while `/mcp/all` returned the raw key. This patch is meant to make the second match the first. --------- Co-authored-by: Algis Dumbris <a.dumbris@gmail.com>
1 parent d25e92b commit 4e02c36

4 files changed

Lines changed: 98 additions & 1 deletion

File tree

‎docs/features/output-sanitisation.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ Spotlighting wraps untrusted text as:
3737
### Scope and limits
3838

3939
- **Default behavior:** nothing is redacted or blocked. Sensitive-data detection still runs and flags records in the activity log, but it does so after the call is recorded and does not change what the agent receives.
40-
- **Two paths are not covered.** (1) **Direct routing mode** (`routing_mode: direct`, tools exposed as `server__tool`) never invokes sanitisation, so `redact` and `block` do not apply to those calls; a matching secret in a direct-mode text response is forwarded unchanged (detection still flags it in the activity log). Sanitisation applies to the `call_tool_*` wrapper path and to `code_execution` tool calls. (2) **Upstream error responses** (`isError: true`) bypass `redact` and `block`, so a secret echoed in an upstream error result is forwarded as-is, even with `response_action: block`.
40+
- **Covered paths.** Sanitisation applies to the `call_tool_*` wrapper path, to direct routing mode (`/mcp/all`, or `routing_mode: direct`, with tools exposed as `server__tool`), and to `code_execution` tool calls.
41+
- **Upstream error responses are not covered.** Upstream error responses (`isError: true`) bypass `redact` and `block`, so a secret echoed in an upstream error result is forwarded as-is, even with `response_action: block`.
4142
- **Text responses only.** `redact` and `block` act on text content in successful tool responses on the covered paths. Image, audio and embedded-resource content is never modified, and tool **arguments** sent to an upstream server are not covered.
4243
- **Pattern-based.** Only values matching the detector's patterns are redacted, and `block` triggers only on a **critical** detection. Redaction replaces the matched span; a multi-line secret such as a PEM private key may leave surrounding lines (in a local check with a synthetic key, only the `BEGIN` line was replaced). Well-known documentation example values (for instance the AWS documentation example access key) are classed as likely examples by the detector and were forwarded unredacted in a local check, while the accompanying secret key was masked. Treat redaction as risk reduction, not a guarantee that no secret reaches the agent.
4344
- **A canary check you can run locally:** point a scratch instance at a fixture server that returns a fake AWS key or private key and compare the response under the default config, `response_action: redact` and `response_action: block`. Use fake values only.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package server
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/mark3labs/mcp-go/mcp"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
12+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/security"
13+
)
14+
15+
// Issue #1683: direct routing mode (/mcp/all, server__tool names) is the fifth
16+
// upstream dispatch path and must honour output_sanitisation like call_tool_*
17+
// and code_execution do. Each test drives the handler mcp-go actually
18+
// registered (directRoutingFixture) against a real in-process upstream that
19+
// returns a secret.
20+
21+
func directSanitisationFixture(t *testing.T, cfg *config.OutputSanitisationConfig, body string) *directRoutingFixture {
22+
t.Helper()
23+
f := newDirectRoutingFixture(t, directServerSpec{server: "github", tools: []toolSpec{readSpec("get_secret")}})
24+
f.proxy.config.OutputSanitisation = cfg
25+
f.proxy.sanitisationDetector = security.NewDetector(config.DefaultSensitiveDataDetectionConfig())
26+
f.ups["github"].okBody.Store(&body)
27+
return f
28+
}
29+
30+
func directText(t *testing.T, r *mcp.CallToolResult) string {
31+
t.Helper()
32+
require.NotEmpty(t, r.Content)
33+
return r.Content[0].(mcp.TextContent).Text
34+
}
35+
36+
func TestDirectModeSanitisation_RedactMasksSecret(t *testing.T) {
37+
f := directSanitisationFixture(t, sanCfg("redact", false, false), "token "+awsKeyFixture+" end")
38+
res := f.call(t, context.Background(), "github", "get_secret")
39+
require.False(t, res.IsError, directText(t, res))
40+
assert.NotContains(t, directText(t, res), awsKeyFixture, "the agent must not receive the raw secret")
41+
assert.Contains(t, directText(t, res), "token ", "non-secret text survives")
42+
}
43+
44+
func TestDirectModeSanitisation_BlockRefusesCriticalResponse(t *testing.T) {
45+
f := directSanitisationFixture(t, sanCfg("block", false, false), awsKeyFixture)
46+
res := f.call(t, context.Background(), "github", "get_secret")
47+
assert.True(t, res.IsError, "a critical detection in block mode must block")
48+
assert.Contains(t, directText(t, res), "blocked by sanitisation policy")
49+
assert.NotContains(t, directText(t, res), awsKeyFixture)
50+
}
51+
52+
func TestDirectModeSanitisation_SpotlightWrapsUntrustedText(t *testing.T) {
53+
f := directSanitisationFixture(t, sanCfg("spotlight", true, false), "hello")
54+
res := f.call(t, context.Background(), "github", "get_secret")
55+
require.False(t, res.IsError, directText(t, res))
56+
text := directText(t, res)
57+
assert.Contains(t, text, "untrusted:github/get_secret")
58+
assert.Contains(t, text, "hello")
59+
}
60+
61+
func TestDirectModeSanitisation_StripRemovesControlSequences(t *testing.T) {
62+
f := directSanitisationFixture(t, sanCfg("spotlight", false, true), "plain\x1b[31mred\x1b[0m")
63+
res := f.call(t, context.Background(), "github", "get_secret")
64+
require.False(t, res.IsError, directText(t, res))
65+
assert.NotContains(t, directText(t, res), "\x1b[")
66+
}
67+
68+
func TestDirectModeSanitisation_DefaultOptOutForwardsUnchanged(t *testing.T) {
69+
f := directSanitisationFixture(t, config.DefaultOutputSanitisationConfig(), "token "+awsKeyFixture)
70+
res := f.call(t, context.Background(), "github", "get_secret")
71+
require.False(t, res.IsError, directText(t, res))
72+
assert.Equal(t, "token "+awsKeyFixture, directText(t, res),
73+
"with no output_sanitisation configured the response is forwarded byte for byte")
74+
}

‎internal/server/mcp_routing.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,15 @@ func (p *MCPProxyServer) makeDirectModeHandler(entry *directCatalogEntry) mcpser
739739
return mcp.NewToolResultError(fmt.Sprintf("Error calling %s:%s: %v", serverName, toolName, err)), nil
740740
}
741741

742+
// Spec 054 Track B (pre-forward): redact secrets, strip control sequences
743+
// or block on a critical detection, exactly as the call_tool_* handlers
744+
// do. Direct mode previously skipped this, so output_sanitisation never
745+
// applied to /mcp/all. Applied to the RAW result before the activity
746+
// classification and truncation below; a non-nil result means blocked.
747+
if blockResult := p.applyOutputSanitisation(ctx, serverName, toolName, requestID, directContentTrust, result); blockResult != nil {
748+
return blockResult, nil
749+
}
750+
742751
// Determine tool variant for activity logging
743752
toolVariant := contracts.DeriveCallWith(annotations)
744753

@@ -812,6 +821,13 @@ func (p *MCPProxyServer) makeDirectModeHandler(entry *directCatalogEntry) mcpser
812821
forwarded = mcp.NewToolResultText(responseText)
813822
}
814823

824+
// Spec 054 Track B (post-forward): spotlight untrusted text in
825+
// source-identifying delimiters. Lossless, applied after truncation;
826+
// responseText is refreshed so the activity record matches what the
827+
// agent receives.
828+
p.spotlightForwarded(serverName, toolName, directContentTrust, forwarded)
829+
responseText = forwardedText(forwarded, responseText)
830+
815831
// Emit completion activity (success, or error when the upstream itself
816832
// reported one — issue #935).
817833
// Spec 069 A1: pre-truncation sizes; result was measured before the truncation loop above.

‎internal/server/scope_fixture_test.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ type countingUpstream struct {
102102
// errBody, when set, makes every tool answer an upstream isError result
103103
// carrying that text (an approved upstream failing on its own).
104104
errBody atomic.Pointer[string]
105+
// okBody, when set, replaces the default "ok" text of a successful answer
106+
// (for tests that need the upstream to return a particular payload).
107+
okBody atomic.Pointer[string]
105108
}
106109

107110
// serve registers one more tool on the running stub upstream, for tests
@@ -130,6 +133,9 @@ func (u *countingUpstream) serve(spec toolSpec) {
130133
if body := u.errBody.Load(); body != nil {
131134
return mcp.NewToolResultError(*body), nil
132135
}
136+
if body := u.okBody.Load(); body != nil {
137+
return mcp.NewToolResultText(*body), nil
138+
}
133139
return mcp.NewToolResultText("ok"), nil
134140
})
135141
}

0 commit comments

Comments
 (0)