Skip to content

Commit 3cafa5a

Browse files
authored
fix(configsvc): deep-copy tool-filter slices in Snapshot.Clone (+ regression guard) (#479)
* fix(configsvc): deep-copy tool-filter slices in Snapshot.Clone Investigation of the reported 'config-file disabled_tools does not reach runtime' concern found NO reproducible code bug: every boundary (file parse, storage Save/Get/List, and the full path parse->New->LoadConfiguredServers->SaveConfiguration->IsToolConfigDenied) provably preserves enabled_tools/disabled_tools. The original manual observation was a test-environment artifact (reused /tmp data-dir while SaveConfiguration rewrites the config file + hot-reload watcher). The investigation did surface one real latent defect: Snapshot.Clone() deep-copied Args/Headers/Env/OAuth/Isolation but shallow-aliased the spec-049 EnabledTools/DisabledTools slices via 'clonedSrv := *srv'. Not data loss (reads see the aliased value) but an immutability violation: mutating a cloned config's filter would corrupt the shared backing array. - Deep-copy EnabledTools/DisabledTools in Snapshot.Clone - TestSnapshotClone_DeepCopiesToolFilters (aliasing regression) - TestConfigFileToolFilter_ReachesRuntime (permanent full-path guard) - correct the spec-049 quickstart note (no #468 gap exists) Related #468 * test(049): make TestConfigFileToolFilter_ReachesRuntime cross-platform The first revision string-interpolated t.TempDir() into a JSON literal; on windows-amd64 the backslash path produced invalid JSON, failing the test (caught by the Build Binaries windows job). Marshal the config via encoding/json so the path is escaped on every OS. Related #468
1 parent 70bf68c commit 3cafa5a

4 files changed

Lines changed: 136 additions & 7 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package runtime
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
"time"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
"go.uber.org/zap"
13+
14+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
15+
)
16+
17+
// TestConfigFileToolFilter_ReachesRuntime is a regression guard for the
18+
// concern raised after spec 049: that config-file enabled_tools/disabled_tools
19+
// on a stdio server might not reach IsToolConfigDenied at runtime.
20+
//
21+
// It exercises the full production path — file → LoadFromFile → runtime.New
22+
// (ConfigService) → LoadConfiguredServers (config→storage sync) →
23+
// SaveConfiguration (storage→ConfigService + file rewrite) — and asserts the
24+
// filter is honored at every stage and survives the config-file rewrite.
25+
func TestConfigFileToolFilter_ReachesRuntime(t *testing.T) {
26+
dir := t.TempDir()
27+
p := filepath.Join(dir, "mcp_config.json")
28+
// json.Marshal so the data_dir path is correctly escaped on every OS
29+
// (Windows temp paths contain backslashes — raw string interpolation into
30+
// a JSON literal produces invalid JSON).
31+
cfgJSON, err := json.Marshal(map[string]any{
32+
"listen": "127.0.0.1:0",
33+
"data_dir": dir,
34+
"api_key": "k",
35+
"mcpServers": []map[string]any{{
36+
"name": "everything", "command": "npx", "args": []string{"-y", "x"},
37+
"protocol": "stdio", "enabled": true, "disabled_tools": []string{"echo"},
38+
}},
39+
})
40+
require.NoError(t, err)
41+
require.NoError(t, os.WriteFile(p, cfgJSON, 0600))
42+
43+
cfg, err := config.LoadFromFile(p)
44+
require.NoError(t, err)
45+
require.Equal(t, []string{"echo"}, cfg.Servers[0].DisabledTools, "parse boundary")
46+
47+
rt, err := New(cfg, p, zap.NewNop())
48+
require.NoError(t, err)
49+
t.Cleanup(func() { _ = rt.Close() })
50+
51+
assert.True(t, rt.IsToolConfigDenied("everything", "echo"), "after New")
52+
assert.False(t, rt.IsToolConfigDenied("everything", "get-tiny-image"), "non-listed tool allowed")
53+
54+
// Sync config → storage (async upstream connect will fail for the fake
55+
// npx binary; the storage save is synchronous and is what we assert on).
56+
_ = rt.LoadConfiguredServers(cfg)
57+
time.Sleep(300 * time.Millisecond)
58+
59+
stored, err := rt.storageManager.ListUpstreamServers()
60+
require.NoError(t, err)
61+
require.Len(t, stored, 1)
62+
assert.Equal(t, []string{"echo"}, stored[0].DisabledTools, "storage after LoadConfiguredServers")
63+
assert.True(t, rt.IsToolConfigDenied("everything", "echo"), "runtime after LoadConfiguredServers")
64+
65+
// Round-trip through storage → ConfigService → config file.
66+
require.NoError(t, rt.SaveConfiguration())
67+
assert.True(t, rt.IsToolConfigDenied("everything", "echo"), "runtime after SaveConfiguration")
68+
69+
rewritten, err := config.LoadFromFile(p)
70+
require.NoError(t, err)
71+
require.Len(t, rewritten.Servers, 1)
72+
assert.Equal(t, []string{"echo"}, rewritten.Servers[0].DisabledTools,
73+
"disabled_tools must survive the SaveConfiguration file rewrite")
74+
}

internal/runtime/configsvc/snapshot.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,19 @@ func (s *Snapshot) Clone() *config.Config {
5050
copy(clonedSrv.Args, srv.Args)
5151
}
5252

53+
// Spec 049: deep-copy the config tool-filter slices. Without
54+
// this, `clonedSrv := *srv` aliases the original backing array,
55+
// so a mutation of a cloned snapshot's filter would corrupt the
56+
// shared config (immutability violation).
57+
if srv.EnabledTools != nil {
58+
clonedSrv.EnabledTools = make([]string, len(srv.EnabledTools))
59+
copy(clonedSrv.EnabledTools, srv.EnabledTools)
60+
}
61+
if srv.DisabledTools != nil {
62+
clonedSrv.DisabledTools = make([]string, len(srv.DisabledTools))
63+
copy(clonedSrv.DisabledTools, srv.DisabledTools)
64+
}
65+
5366
// Clone OAuth config if present
5467
if srv.OAuth != nil {
5568
oauthClone := *srv.OAuth
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package configsvc
2+
3+
import (
4+
"testing"
5+
6+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
7+
)
8+
9+
// Spec 049: Clone must deep-copy EnabledTools/DisabledTools so a mutation of
10+
// the cloned config cannot corrupt the original snapshot's backing array.
11+
func TestSnapshotClone_DeepCopiesToolFilters(t *testing.T) {
12+
orig := &config.ServerConfig{
13+
Name: "s",
14+
EnabledTools: []string{"a", "b"},
15+
DisabledTools: []string{"x", "y"},
16+
}
17+
s := &Snapshot{Config: &config.Config{Servers: []*config.ServerConfig{orig}}}
18+
19+
cloned := s.Clone()
20+
cs := cloned.Servers[0]
21+
22+
// Distinct backing arrays.
23+
cs.DisabledTools[0] = "MUTATED"
24+
cs.EnabledTools[0] = "MUTATED"
25+
if orig.DisabledTools[0] != "x" {
26+
t.Fatalf("DisabledTools aliased: original corrupted to %v", orig.DisabledTools)
27+
}
28+
if orig.EnabledTools[0] != "a" {
29+
t.Fatalf("EnabledTools aliased: original corrupted to %v", orig.EnabledTools)
30+
}
31+
// Values still copied correctly (no data loss).
32+
cloned2 := s.Clone()
33+
if len(cloned2.Servers[0].DisabledTools) != 2 || cloned2.Servers[0].DisabledTools[1] != "y" {
34+
t.Fatalf("DisabledTools not copied: %v", cloned2.Servers[0].DisabledTools)
35+
}
36+
}

specs/049-agent-discoverable-disabled-tools/quickstart.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,16 @@ pkill -f 'mcpproxy serve.*18049' 2>/dev/null
128128
| §1 `upstream_servers``{callable:12,disabled_by_user:1}`, zero reasons omitted; all-callable server emits no block | ✅ PASS |
129129
| §5 zero-result nudge | ✅ PASS (unit: `TestDisabledDiscovery_ZeroResultNudge`) |
130130

131-
**Known limitation (pre-existing, NOT spec 049):** config-file `disabled_tools`
132-
on a stdio server did not reach `IsToolConfigDenied` at runtime in this manual
133-
run (`disabled_tools` showed null in the live server view; an `echo` call
134-
succeeded). The 049 classifier maps `disabled_by_config` correctly whenever
135-
`IsToolConfigDenied` fires (proven by `TestClassifyDisabledTool_Precedence` and
136-
the §4 call-path message). This config→runtime load gap belongs to #468 and is
137-
filed separately; it does not affect 049 correctness.
131+
**Investigated and resolved — there is no config→runtime gap.** The manual
132+
run that appeared to show config-file `disabled_tools` not reaching
133+
`IsToolConfigDenied` was a test-environment artifact (a `/tmp` data-dir reused
134+
across runs while mcpproxy's own `SaveConfiguration()` rewrites the config
135+
file, plus the hot-reload watcher — the effective config at the `echo` call
136+
was indeterminate). Systematic debugging proved every boundary preserves the
137+
filter: file parse, storage `Save→Get/List`, and the full path
138+
parse→`New``LoadConfiguredServers``SaveConfiguration``IsToolConfigDenied`.
139+
This is now permanently guarded by
140+
`TestConfigFileToolFilter_ReachesRuntime` (internal/runtime). The investigation
141+
did surface and fix one real latent defect — `configsvc.Snapshot.Clone()`
142+
shallow-aliased the `EnabledTools`/`DisabledTools` slices (immutability
143+
violation, not data loss) — fixed with `TestSnapshotClone_DeepCopiesToolFilters`.

0 commit comments

Comments
 (0)