Skip to content

Commit 3e7f20d

Browse files
committed
feat(api): expose & persist auto_approve_tool_changes in server REST API (MCP-2940)
MCP-2930 (#724) added the per-server auto_approve_tool_changes flag to config.ServerConfig but did not extend the REST layer, so the Web UI toggle (MCP-2932, PR #725) could neither read nor persist it. REST request side: - Add AutoApproveToolChanges *bool to httpapi.AddServerRequest. - Wire it into handleAddServer (create) and handlePatchServer with *bool nil-preserve semantics: omitting it on PATCH preserves the existing pointer (which may be nil = "never set"); an explicit value (including false) is applied. server.UpdateServer applies the pointer only when non-nil so callers that don't touch it (e.g. config-to-secret) never reset it. REST read side: - Add AutoApproveToolChanges *bool to contracts.Server (omitempty → tri-state nil stays out of the payload so the UI can tell unset from explicit false). - Emit auto_approve_tool_changes from runtime.GetAllServers (StateView path) and getAllServersLegacy, in parity with quarantined. - Project it in management.ListServers and ConvertGenericServersToTyped / ConvertServerConfig. Persistence: - SaveConfiguration rebuilds the JSON config's server list from BBolt records, so a field absent from storage.UpstreamRecord is wiped on the next mutation. Add AutoApproveToolChanges to UpstreamRecord and all four selective conversions (Save/Get/List in manager.go, saveServerSync in async_ops.go); update TestSaveServerSyncFieldCoverage to require it in BBolt. Verified the flag now survives PATCH→GET and a full restart. Tests: handler PATCH (set/preserve-nil/preserve-true/explicit-false) + GET exposure; management + contracts projection; storage Save/Get/List round-trip incl. tri-state nil. OpenAPI regenerated. Docs updated (tool-quarantine.md, rest-api.md). Related #725. Runtime enforcement remains MCP-2931.
1 parent 40969d1 commit 3e7f20d

17 files changed

Lines changed: 486 additions & 100 deletions

File tree

docs/api/rest-api.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ mask string.
231231
"protocol": "http",
232232
"enabled": true,
233233
"quarantined": false,
234+
"auto_approve_tool_changes": true,
234235
"isolation": {"enabled": true, "image": "node:20"}
235236
}
236237
```
@@ -258,8 +259,11 @@ curl -X PATCH -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
258259

259260
- Empty string `""` is **set-to-empty**, NOT delete. JSON Merge Patch is
260261
explicit about this — only the JSON `null` token deletes.
261-
- Boolean fields (`enabled`, `quarantined`, `reconnect_on_use`) use
262-
pointer-style semantics: absent = preserve, present = explicit value.
262+
- Boolean fields (`enabled`, `quarantined`, `reconnect_on_use`,
263+
`auto_approve_tool_changes`) use pointer-style semantics: absent = preserve,
264+
present = explicit value. `auto_approve_tool_changes` is tri-state — it is
265+
omitted entirely from `GET /api/v1/servers` responses when never set, so a
266+
client can distinguish "unset" from an explicit `false`.
263267

264268
#### POST /api/v1/servers/{name}/config-to-secret
265269

docs/features/tool-quarantine.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ Trust specific servers by skipping per-server tool-change review:
8282

8383
> **Note — rollout:** `auto_approve_tool_changes` is config-plumbed but its **enforcement is not yet wired** — runtime auto-approval is still governed by `skip_quarantine`. The new flag becomes the active control (and gains the richer rug-pull / trust-baseline behavior) in an upcoming release. Existing `skip_quarantine` configs are unaffected and are migrated onto the new key automatically. To auto-approve a server today, set `skip_quarantine: true`.
8484
85+
> **REST API:** `auto_approve_tool_changes` round-trips through the server REST API. `GET /api/v1/servers` includes it on each server object (omitted when unset — the tri-state nil is preserved so the Web UI can tell "never set" from an explicit `false`), and `POST`/`PATCH /api/v1/servers/{id}` accept it. As a tri-state `*bool`, omitting it on `PATCH` leaves the stored value unchanged; sending an explicit `false` clears a prior `true`. The value is persisted to BBolt so it survives a save/restart.
86+
8587
When the active control is enabled for a server, new tools from it are automatically approved.
8688

8789
### Auto-Approve Behavior

internal/contracts/converters.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ func ConvertServerConfig(cfg *config.ServerConfig, status string, connected bool
3333
// can show a server's origin. Empty for manually-configured servers.
3434
SourceRegistryID: cfg.SourceRegistryID,
3535
SourceRegistryProvenance: cfg.SourceRegistryProvenance,
36+
// MCP-2940: surface the per-server auto-approve intent (tri-state *bool)
37+
// so the Web UI toggle reflects the persisted value.
38+
AutoApproveToolChanges: cfg.AutoApproveToolChanges,
3639
}
3740

3841
// Convert OAuth config if present
@@ -177,6 +180,13 @@ func ConvertGenericServersToTyped(genericServers []map[string]interface{}) []Ser
177180
if quarantined, ok := generic["quarantined"].(bool); ok {
178181
server.Quarantined = quarantined
179182
}
183+
// MCP-2940: tri-state *bool — only set the pointer when the key is
184+
// present so an unset flag stays nil (the Web UI distinguishes unset
185+
// from an explicit false).
186+
if autoApprove, ok := generic["auto_approve_tool_changes"].(bool); ok {
187+
v := autoApprove
188+
server.AutoApproveToolChanges = &v
189+
}
180190
if connected, ok := generic["connected"].(bool); ok {
181191
server.Connected = connected
182192
}

internal/contracts/converters_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,43 @@ func TestConvertServerConfig_SourceRegistry(t *testing.T) {
200200
assert.Empty(t, manual.SourceRegistryProvenance)
201201
}
202202

203+
// TestConvertGenericServersToTyped_AutoApproveToolChanges verifies the
204+
// per-server auto_approve_tool_changes flag (MCP-2940) survives the
205+
// generic-map fallback projection so the Web UI toggle can read it. A server
206+
// that never set the flag must leave the pointer nil (tri-state), not coerce
207+
// to false.
208+
func TestConvertGenericServersToTyped_AutoApproveToolChanges(t *testing.T) {
209+
genericServers := []map[string]interface{}{
210+
{"id": "on", "name": "on", "enabled": true, "auto_approve_tool_changes": true},
211+
{"id": "off", "name": "off", "enabled": true, "auto_approve_tool_changes": false},
212+
{"id": "unset", "name": "unset", "enabled": true},
213+
}
214+
215+
servers := ConvertGenericServersToTyped(genericServers)
216+
require.Len(t, servers, 3)
217+
218+
require.NotNil(t, servers[0].AutoApproveToolChanges)
219+
assert.True(t, *servers[0].AutoApproveToolChanges)
220+
221+
require.NotNil(t, servers[1].AutoApproveToolChanges)
222+
assert.False(t, *servers[1].AutoApproveToolChanges)
223+
224+
assert.Nil(t, servers[2].AutoApproveToolChanges, "unset flag must stay nil, not coerce to false")
225+
}
226+
227+
// TestConvertServerConfig_AutoApproveToolChanges verifies the direct
228+
// config→contracts mapper carries the auto_approve_tool_changes pointer.
229+
func TestConvertServerConfig_AutoApproveToolChanges(t *testing.T) {
230+
on := true
231+
cfg := &config.ServerConfig{Name: "on", Enabled: true, AutoApproveToolChanges: &on}
232+
server := ConvertServerConfig(cfg, "ready", true, 0, false)
233+
require.NotNil(t, server.AutoApproveToolChanges)
234+
assert.True(t, *server.AutoApproveToolChanges)
235+
236+
unset := ConvertServerConfig(&config.ServerConfig{Name: "unset", Enabled: true}, "ready", true, 0, false)
237+
assert.Nil(t, unset.AutoApproveToolChanges)
238+
}
239+
203240
// TestConvertGenericServersToTyped_NoOAuth verifies servers without OAuth have nil OAuth field
204241
func TestConvertGenericServersToTyped_NoOAuth(t *testing.T) {
205242
genericServers := []map[string]interface{}{

internal/contracts/types.go

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,19 +41,26 @@ type Server struct {
4141
// IsolationDefaults exposes the resolved baseline values that
4242
// would apply when no per-server override is set. Populated on
4343
// list/get responses; never consumed on PATCH requests.
44-
IsolationDefaults *IsolationDefaults `json:"isolation_defaults,omitempty"`
45-
Authenticated bool `json:"authenticated"` // OAuth authentication status
46-
OAuthStatus string `json:"oauth_status,omitempty"` // OAuth status: "authenticated", "expired", "error", "none"
47-
TokenExpiresAt *time.Time `json:"token_expires_at,omitempty"` // When the OAuth token expires (ISO 8601)
48-
ToolListTokenSize int `json:"tool_list_token_size,omitempty"` // Token size for this server's tools
49-
ShouldRetry bool `json:"should_retry,omitempty"`
50-
RetryCount int `json:"retry_count,omitempty"`
51-
LastRetryTime *time.Time `json:"last_retry_time,omitempty"`
52-
UserLoggedOut bool `json:"user_logged_out,omitempty"` // True if user explicitly logged out (prevents auto-reconnection)
53-
Health *HealthStatus `json:"health,omitempty"` // Unified health status calculated by the backend
54-
Quarantine *QuarantineStats `json:"quarantine,omitempty"` // Tool quarantine metrics for this server
55-
ReconnectOnUse bool `json:"reconnect_on_use,omitempty"` // Attempt reconnection when a tool call targets this disconnected server
56-
SecurityScan *SecurityScanSummary `json:"security_scan,omitempty"` // Latest security scan results summary
44+
IsolationDefaults *IsolationDefaults `json:"isolation_defaults,omitempty"`
45+
Authenticated bool `json:"authenticated"` // OAuth authentication status
46+
OAuthStatus string `json:"oauth_status,omitempty"` // OAuth status: "authenticated", "expired", "error", "none"
47+
TokenExpiresAt *time.Time `json:"token_expires_at,omitempty"` // When the OAuth token expires (ISO 8601)
48+
ToolListTokenSize int `json:"tool_list_token_size,omitempty"` // Token size for this server's tools
49+
ShouldRetry bool `json:"should_retry,omitempty"`
50+
RetryCount int `json:"retry_count,omitempty"`
51+
LastRetryTime *time.Time `json:"last_retry_time,omitempty"`
52+
UserLoggedOut bool `json:"user_logged_out,omitempty"` // True if user explicitly logged out (prevents auto-reconnection)
53+
Health *HealthStatus `json:"health,omitempty"` // Unified health status calculated by the backend
54+
Quarantine *QuarantineStats `json:"quarantine,omitempty"` // Tool quarantine metrics for this server
55+
ReconnectOnUse bool `json:"reconnect_on_use,omitempty"` // Attempt reconnection when a tool call targets this disconnected server
56+
// AutoApproveToolChanges mirrors config.ServerConfig.AutoApproveToolChanges
57+
// (MCP-2930): the per-server intent to auto-approve new/changed tools past
58+
// the trust baseline. Tri-state *bool — nil means "never set" (omitted from
59+
// the payload), so the Web UI toggle (MCP-2932) can distinguish unset from
60+
// an explicit false. Read-only on the GET path; PATCH/POST accept it via
61+
// AddServerRequest.
62+
AutoApproveToolChanges *bool `json:"auto_approve_tool_changes,omitempty"`
63+
SecurityScan *SecurityScanSummary `json:"security_scan,omitempty"` // Latest security scan results summary
5764
// Spec 044 — structured diagnostic error and stable error code. Both
5865
// are populated when the server is in a failed state and the error
5966
// has been classified by internal/diagnostics. Healthy servers omit

internal/httpapi/patch_server_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ type mockPatchServerController struct {
2323
apiKey string
2424
existingServer *config.ServerConfig
2525
capturedUpdates *config.ServerConfig
26+
allServers []map[string]interface{}
27+
}
28+
29+
// GetAllServers feeds the legacy GET /api/v1/servers path (used when no
30+
// management service is wired). Returning generic maps lets us assert how
31+
// the handler projects server-status fields into the JSON response.
32+
func (m *mockPatchServerController) GetAllServers() ([]map[string]interface{}, error) {
33+
return m.allServers, nil
2634
}
2735

2836
func (m *mockPatchServerController) GetCurrentConfig() any {
@@ -445,6 +453,115 @@ func TestHandleConvertConfigToSecret_ValidationErrors(t *testing.T) {
445453
}
446454
}
447455

456+
// TestHandlePatchServer_AutoApproveToolChanges covers the MCP-2940 gap: the
457+
// REST PATCH layer must accept and persist the per-server
458+
// auto_approve_tool_changes flag (a tri-state *bool added to ServerConfig in
459+
// MCP-2930). Mirrors the *bool nil-preserve semantics — omitting the field on
460+
// PATCH must not clobber a previously-set value, while an explicit value
461+
// (including false) must round-trip.
462+
func TestHandlePatchServer_AutoApproveToolChanges(t *testing.T) {
463+
logger := zap.NewNop().Sugar()
464+
465+
boolPtr := func(b bool) *bool { return &b }
466+
467+
newServer := func() *config.ServerConfig {
468+
return &config.ServerConfig{
469+
Name: "github",
470+
Protocol: "stdio",
471+
Command: "npx",
472+
Enabled: true,
473+
Quarantined: false,
474+
}
475+
}
476+
477+
patch := func(t *testing.T, existing *config.ServerConfig, body string) *config.ServerConfig {
478+
t.Helper()
479+
mockCtrl := &mockPatchServerController{apiKey: "test-key", existingServer: existing}
480+
srv := NewServer(mockCtrl, logger, nil)
481+
req := httptest.NewRequest(http.MethodPatch, "/api/v1/servers/github", bytes.NewReader([]byte(body)))
482+
req.Header.Set("Content-Type", "application/json")
483+
req.Header.Set("X-API-Key", "test-key")
484+
w := httptest.NewRecorder()
485+
srv.ServeHTTP(w, req)
486+
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
487+
require.NotNil(t, mockCtrl.capturedUpdates, "UpdateServer should have been called")
488+
return mockCtrl.capturedUpdates
489+
}
490+
491+
t.Run("explicit true sets the pointer", func(t *testing.T) {
492+
updates := patch(t, newServer(), `{"auto_approve_tool_changes":true}`)
493+
require.NotNil(t, updates.AutoApproveToolChanges, "pointer must be set when the request provides it")
494+
assert.True(t, *updates.AutoApproveToolChanges, "value must reflect the request (true)")
495+
})
496+
497+
t.Run("omitting preserves nil existing", func(t *testing.T) {
498+
existing := newServer() // AutoApproveToolChanges is nil
499+
updates := patch(t, existing, `{"args":["new-arg"]}`)
500+
assert.Nil(t, updates.AutoApproveToolChanges,
501+
"omitting the field must preserve the unset (nil) existing value")
502+
})
503+
504+
t.Run("omitting preserves a prior true", func(t *testing.T) {
505+
existing := newServer()
506+
existing.AutoApproveToolChanges = boolPtr(true)
507+
updates := patch(t, existing, `{"args":["new-arg"]}`)
508+
require.NotNil(t, updates.AutoApproveToolChanges,
509+
"omitting the field must preserve the existing pointer")
510+
assert.True(t, *updates.AutoApproveToolChanges, "prior true must be preserved")
511+
})
512+
513+
t.Run("explicit false overrides a prior true", func(t *testing.T) {
514+
existing := newServer()
515+
existing.AutoApproveToolChanges = boolPtr(true)
516+
updates := patch(t, existing, `{"auto_approve_tool_changes":false}`)
517+
require.NotNil(t, updates.AutoApproveToolChanges,
518+
"explicit false must set the pointer, not leave it nil")
519+
assert.False(t, *updates.AutoApproveToolChanges, "explicit false must override the prior true")
520+
})
521+
}
522+
523+
// TestHandleGetServers_ExposesAutoApproveToolChanges verifies the GET
524+
// /api/v1/servers payload surfaces auto_approve_tool_changes so the Web UI
525+
// toggle can reflect the persisted value (MCP-2940). Exercises the legacy
526+
// projection path (ConvertGenericServersToTyped) via a generic server map.
527+
func TestHandleGetServers_ExposesAutoApproveToolChanges(t *testing.T) {
528+
logger := zap.NewNop().Sugar()
529+
mockCtrl := &mockPatchServerController{
530+
apiKey: "test-key",
531+
allServers: []map[string]interface{}{
532+
{
533+
"id": "github",
534+
"name": "github",
535+
"enabled": true,
536+
"quarantined": false,
537+
"auto_approve_tool_changes": true,
538+
},
539+
},
540+
}
541+
srv := NewServer(mockCtrl, logger, nil)
542+
543+
req := httptest.NewRequest(http.MethodGet, "/api/v1/servers", http.NoBody)
544+
req.Header.Set("X-API-Key", "test-key")
545+
w := httptest.NewRecorder()
546+
srv.ServeHTTP(w, req)
547+
548+
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
549+
550+
var resp struct {
551+
Data struct {
552+
Servers []struct {
553+
Name string `json:"name"`
554+
AutoApproveToolChanges *bool `json:"auto_approve_tool_changes"`
555+
} `json:"servers"`
556+
} `json:"data"`
557+
}
558+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
559+
require.Len(t, resp.Data.Servers, 1)
560+
require.NotNil(t, resp.Data.Servers[0].AutoApproveToolChanges,
561+
"auto_approve_tool_changes must appear in the GET payload")
562+
assert.True(t, *resp.Data.Servers[0].AutoApproveToolChanges)
563+
}
564+
448565
// TestHandleConvertConfigToSecret_ServerNotFound verifies the 404 path
449566
// for an unknown server name. Separate from the validation-errors table
450567
// because it needs an empty server list, not a single populated entry.

internal/httpapi/server.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1326,6 +1326,13 @@ type AddServerRequest struct {
13261326
Enabled *bool `json:"enabled,omitempty"`
13271327
Quarantined *bool `json:"quarantined,omitempty"`
13281328
ReconnectOnUse *bool `json:"reconnect_on_use,omitempty"`
1329+
// AutoApproveToolChanges is the per-server intent to auto-approve
1330+
// new/changed tools past the trust baseline (MCP-2930). Tri-state *bool:
1331+
// a nil pointer means "leave unchanged" on PATCH; a present value
1332+
// (including false) is applied. Mirrors config.ServerConfig's *bool
1333+
// semantics — do NOT collapse to a plain bool, or an omitted field would
1334+
// silently reset a previously-set value.
1335+
AutoApproveToolChanges *bool `json:"auto_approve_tool_changes,omitempty"`
13291336
// Isolation carries per-server Docker isolation overrides (image,
13301337
// network_mode, extra_args, working_dir, enabled). A nil pointer
13311338
// means "do not touch isolation config"; an empty-but-present
@@ -1450,6 +1457,11 @@ func (s *Server) handleAddServer(w http.ResponseWriter, r *http.Request) {
14501457
if req.ReconnectOnUse != nil {
14511458
serverConfig.ReconnectOnUse = *req.ReconnectOnUse
14521459
}
1460+
// MCP-2940: carry the per-server auto-approve intent through on create.
1461+
// *bool nil-preserve semantics: only set when the caller provided it.
1462+
if req.AutoApproveToolChanges != nil {
1463+
serverConfig.AutoApproveToolChanges = req.AutoApproveToolChanges
1464+
}
14531465

14541466
// Add server via controller
14551467
logger := s.getRequestLogger(r) // T019: Use request-scoped logger
@@ -1639,6 +1651,17 @@ func (s *Server) handlePatchServer(w http.ResponseWriter, r *http.Request) {
16391651
} else if existingSrv != nil {
16401652
updates.ReconnectOnUse = existingSrv.ReconnectOnUse
16411653
}
1654+
// MCP-2940: auto_approve_tool_changes is a tri-state *bool, so unlike the
1655+
// non-pointer bools above we preserve the EXISTING POINTER (which may be
1656+
// nil = "never set") when the request omits the field — collapsing to a
1657+
// plain bool here would erase the unset/false distinction the trust-baseline
1658+
// logic (MCP-2931) relies on.
1659+
if req.AutoApproveToolChanges != nil {
1660+
updates.AutoApproveToolChanges = req.AutoApproveToolChanges
1661+
hasUpdates = true
1662+
} else if existingSrv != nil {
1663+
updates.AutoApproveToolChanges = existingSrv.AutoApproveToolChanges
1664+
}
16421665
if req.Isolation != nil {
16431666
updates.Isolation = req.Isolation.toConfig()
16441667
hasUpdates = true

internal/management/service.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,14 @@ func (s *service) ListServers(ctx context.Context) ([]*contracts.Server, *contra
478478
srv.ReconnectOnUse = reconnectOnUse
479479
}
480480

481+
// MCP-2940: project the per-server auto-approve intent. Tri-state
482+
// *bool — only set the pointer when the key is present so an unset
483+
// flag stays nil and the Web UI toggle can tell unset from false.
484+
if autoApprove, ok := srvRaw["auto_approve_tool_changes"].(bool); ok {
485+
v := autoApprove
486+
srv.AutoApproveToolChanges = &v
487+
}
488+
481489
// Extract unified health status
482490
if health, ok := srvRaw["health"].(*contracts.HealthStatus); ok {
483491
srv.Health = health

internal/management/service_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,52 @@ func TestListServers(t *testing.T) {
163163
assert.Empty(t, byName["manual"].SourceRegistryProvenance)
164164
})
165165

166+
// MCP-2940: auto_approve_tool_changes must be projected onto contracts.Server
167+
// so the Web UI toggle (MCP-2932) can reflect the persisted per-server value.
168+
t.Run("auto_approve_tool_changes projected", func(t *testing.T) {
169+
runtime := newMockRuntime()
170+
runtime.servers = []map[string]interface{}{
171+
{
172+
"id": "auto-on",
173+
"name": "auto-on",
174+
"enabled": true,
175+
"auto_approve_tool_changes": true,
176+
},
177+
{
178+
"id": "auto-off",
179+
"name": "auto-off",
180+
"enabled": true,
181+
"auto_approve_tool_changes": false,
182+
},
183+
{
184+
"id": "unset",
185+
"name": "unset",
186+
"enabled": true,
187+
},
188+
}
189+
190+
svc := NewService(runtime, cfg, "", emitter, nil, logger)
191+
servers, _, err := svc.ListServers(context.Background())
192+
require.NoError(t, err)
193+
require.Len(t, servers, 3)
194+
195+
byName := map[string]*contracts.Server{}
196+
for _, s := range servers {
197+
byName[s.Name] = s
198+
}
199+
require.Contains(t, byName, "auto-on")
200+
require.NotNil(t, byName["auto-on"].AutoApproveToolChanges)
201+
assert.True(t, *byName["auto-on"].AutoApproveToolChanges)
202+
203+
require.Contains(t, byName, "auto-off")
204+
require.NotNil(t, byName["auto-off"].AutoApproveToolChanges)
205+
assert.False(t, *byName["auto-off"].AutoApproveToolChanges)
206+
207+
require.Contains(t, byName, "unset")
208+
assert.Nil(t, byName["unset"].AutoApproveToolChanges,
209+
"a server that never set the flag must omit it (nil), not coerce to false")
210+
})
211+
166212
// T094: Test that TotalTools only counts enabled servers' tools (Issue #285 fix)
167213
t.Run("TotalTools excludes disabled servers", func(t *testing.T) {
168214
runtime := newMockRuntime()

0 commit comments

Comments
 (0)