Skip to content

Commit 3ffa41e

Browse files
nlauranceclaude
andauthored
feat(config): layered config tool filter — config_denied over user toggles (#468)
* feat(config): add enabled_tools/disabled_tools per-server allowlist/denylist Adds two mutually exclusive fields to ServerConfig that let operators declare tool visibility statically in mcp_config.json rather than having to call the API or CLI after every fresh install. enabled_tools: ["list_issues", "get_issue"] // allowlist — only these visible disabled_tools: ["delete_repo", "force_push"] // denylist — hide these, allow rest Config validation rejects a server that has both fields set. On every applyDifferentialToolUpdate (server connect / tool refresh), applyConfigToolFilter walks the in-memory config, computes the desired enabled/disabled state for each discovered tool, and calls setToolEnabledNoEmit to persist it in BBolt. All existing enforcement paths (isToolCallable, retrieve_tools pre-ranking, call_tool_*) pick up the change automatically with no further modifications. Five unit tests cover: allowlist disables unlisted tools, allowlist re-enables a tool moved back into the list, denylist disables listed tools, no-op when neither field is set, and end-to-end integration through applyDifferentialToolUpdate. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(config): add IsToolAllowedByConfig helper on ServerConfig * feat(runtime): replace BBolt-writing applyConfigToolFilter with IsToolConfigDenied Replace the BBolt-writing applyConfigToolFilter function (which overwrote user preferences on every reconnect, emitted spurious audit events, and had no provenance) with an evaluation-time IsToolConfigDenied method that: - Reads config at call time, never writes to BBolt - Preserves user-set tool preferences and audit trail - Enables separation of concerns: config vs user intent Key changes: - Delete applyConfigToolFilter from tool_quarantine.go (63 lines removed) - Add IsToolConfigDenied(serverName, toolName string) bool to Runtime - Remove applyConfigToolFilter call from lifecycle.go - Rewrite tool_config_filter_test.go: 5 new tests for IsToolConfigDenied * AllowList: tools not listed are denied * DenyList: listed tools are denied * NoFilter: all tools allowed when config has no filter * UnknownServer: returns false for missing servers * UserDisabledPreserved: BBolt state is independent from config layer All 198 runtime tests pass. No behavior change to actual tool visibility— the config layer is now just evaluated at call time instead of persisted. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(runtime): skip config-denied tools in SetAllToolsEnabled bulk toggle * feat(server): enforce config tool filter in isToolCallable Add IsToolConfigDenied delegation on *Server and insert a config-layer check in isToolCallable so tools denied by enabled_tools/disabled_tools in the server config are hard-off at MCP call time, evaluated at runtime without touching BBolt storage. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(api): expose config_denied on tool response; reject enabling config-denied tools - Add ConfigDenied bool field to contracts.Tool (json: config_denied,omitempty) - Enrich config_denied in handleGetServerTools via IsToolConfigDenied interface - Return HTTP 409 in handleSetToolEnabled when req.Enabled is true for a config-denied tool - Remove debug fmt.Printf lines from enrichment loop; use logger.Debug instead Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ui): show locked badge and disable toggle for config-denied tools Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(types): add disabled/config_denied fields to Tool interface Extended the Tool interface to include runtime fields that the backend sends and Vue components use: server_name, schema, usage, last_used, approval_status, disabled, and config_denied. This allows proper typing of these fields in the frontend instead of using unsafe casts. Simplified type assertions in isToolConfigDenied and isToolEnabled functions to use the properly-typed Tool interface directly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(storage): persist EnabledTools/DisabledTools fields through BBolt round-trip * chore(oas): regenerate OpenAPI spec with config_denied tool field * chore: trigger GitHub merge-check recalculation * fix(ux): status-aware TOOL_BLOCKED + lock-badge polish (review #468 #1-#4) #1 (bug): config-denied tools returned the generic 'Tool is disabled and not callable' over MCP — identical to a user-toggled tool, but the remediation differs (edit mcp_config.json vs a UI toggle that 409s). blockedToolMessage() now branches on IsToolConfigDenied and tells the agent it is operator policy, not user-overridable. Split into a pure blockedToolMessageFor(bool) with a dedicated unit test. #2: config-lock badges badge-error (red/alarming) -> badge-neutral + 🔒; a policy lock is not an error. #3: 'Enable All' toast now reports 'N tools remain locked by config' (client-side from config_denied; no API contract change). #4: unified copy — stray 'config locked' label -> '🔒 locked by config'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0afa3fb commit 3ffa41e

18 files changed

Lines changed: 399 additions & 23 deletions

File tree

frontend/src/types/api.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,15 @@ export interface Tool {
244244
name: string
245245
description: string
246246
server: string
247+
server_name?: string
247248
input_schema?: Record<string, any>
249+
schema?: Record<string, any>
248250
annotations?: ToolAnnotation
251+
usage?: number
252+
last_used?: string
253+
approval_status?: string
254+
disabled?: boolean
255+
config_denied?: boolean
249256
}
250257

251258
// Tool approval types (Spec 032)

frontend/src/views/ServerDetail.vue

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,14 @@
388388
</div>
389389
</div>
390390
</div>
391+
<template v-if="isToolConfigDenied(tool.tool_name)">
392+
<span
393+
class="badge badge-neutral badge-sm ml-4 self-center"
394+
title="Tool is denied by mcp_config.json; approval has no effect while the config lock is active"
395+
>🔒 locked by config</span>
396+
</template>
391397
<button
398+
v-else
392399
@click="approveTool(tool.tool_name)"
393400
:disabled="approvalLoading"
394401
class="btn btn-sm btn-outline ml-4"
@@ -487,6 +494,11 @@
487494
v-else-if="getToolApprovalStatus(tool.name) === 'changed'"
488495
class="badge badge-warning badge-sm"
489496
>changed</span>
497+
<span
498+
v-if="isToolConfigDenied(tool.name)"
499+
class="badge badge-neutral badge-sm"
500+
title="Disabled by mcp_config.json (enabled_tools / disabled_tools)"
501+
>🔒 locked by config</span>
490502
</div>
491503
<label
492504
v-if="isToolToggleAvailable(tool.name)"
@@ -504,6 +516,11 @@
504516
@change="toggleToolEnabled(tool.name, ($event.target as HTMLInputElement).checked)"
505517
/>
506518
</label>
519+
<span
520+
v-else-if="isToolConfigDenied(tool.name)"
521+
class="text-xs text-base-content/40 shrink-0 italic"
522+
title="Remove from disabled_tools or add to enabled_tools in mcp_config.json to unlock"
523+
>🔒 locked by config</span>
507524
</div>
508525
<div
509526
class="transition-opacity"
@@ -1389,16 +1406,20 @@ function getToolApproval(toolName: string): ToolApproval | null {
13891406
return toolApprovals.value.find(t => t.tool_name === toolName) || null
13901407
}
13911408
1409+
function isToolConfigDenied(toolName: string): boolean {
1410+
const tool = serverTools.value.find(t => t.name === toolName)
1411+
return tool?.config_denied === true
1412+
}
1413+
13921414
function isToolEnabled(toolName: string): boolean {
13931415
// GET /api/v1/servers/{id}/tools returns each tool with a top-level
13941416
// `disabled` boolean (see contracts.Tool.Disabled in Go) when an approval
13951417
// record exists. The approvals endpoint also exposes `enabled`/`disabled`.
13961418
// Cross-check both so the toggle reflects reality regardless of which
13971419
// payload the frontend already loaded.
1398-
const tool = serverTools.value.find(t => t.name === toolName) as Tool & { disabled?: boolean; enabled?: boolean } | undefined
1420+
const tool = serverTools.value.find(t => t.name === toolName)
13991421
if (tool) {
14001422
if (typeof tool.disabled === 'boolean') return !tool.disabled
1401-
if (typeof tool.enabled === 'boolean') return tool.enabled
14021423
}
14031424
const approval = getToolApproval(toolName)
14041425
if (!approval) return true
@@ -1416,6 +1437,7 @@ function isToolToggleLoading(toolName: string): boolean {
14161437
// tools the daemon synthesizes an approval record on demand, so the toggle
14171438
// works in every other case.
14181439
function isToolToggleAvailable(toolName: string): boolean {
1440+
if (isToolConfigDenied(toolName)) return false
14191441
const status = getToolApprovalStatus(toolName)
14201442
return status === null || status === 'approved'
14211443
}
@@ -1921,12 +1943,22 @@ async function bulkToggleAllTools(enabled: boolean) {
19211943
const response = await api.setAllToolsEnabled(server.value.name, enabled)
19221944
if (response.success && response.data) {
19231945
const changed = response.data.changed ?? 0
1946+
// "Enable All" intentionally skips tools the server config denies
1947+
// (enabled_tools/disabled_tools) — surface that so the user isn't
1948+
// left wondering why some toggles stayed locked.
1949+
const lockedByConfig = enabled
1950+
? serverTools.value.filter(t => t.config_denied === true).length
1951+
: 0
1952+
const baseMsg = changed === 0
1953+
? 'No tools needed changes.'
1954+
: `${changed} tool${changed === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}.`
1955+
const lockedMsg = lockedByConfig > 0
1956+
? ` ${lockedByConfig} tool${lockedByConfig === 1 ? '' : 's'} remain locked by config.`
1957+
: ''
19241958
systemStore.addToast({
19251959
type: 'success',
19261960
title: enabled ? 'Tools Enabled' : 'Tools Disabled',
1927-
message: changed === 0
1928-
? 'No tools needed changes.'
1929-
: `${changed} tool${changed === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}.`,
1961+
message: baseMsg + lockedMsg,
19301962
})
19311963
// Refresh server data + tool caches so the per-tool toggle, the
19321964
// "N disabled" pill, and the Server List both lose any staleness.

internal/config/config.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,9 @@ type ServerConfig struct {
239239
// when the server is configured with both Command and an HTTP/SSE URL — i.e.,
240240
// mcpproxy starts the process AND connects via network. Stdio servers ignore
241241
// this field. Zero or unset → 30s default.
242-
LauncherWaitTimeout Duration `json:"launcher_wait_timeout,omitempty" mapstructure:"launcher_wait_timeout" swaggertype:"string"`
242+
LauncherWaitTimeout Duration `json:"launcher_wait_timeout,omitempty" mapstructure:"launcher_wait_timeout" swaggertype:"string"`
243+
EnabledTools []string `json:"enabled_tools,omitempty" mapstructure:"enabled_tools"` // Allowlist: only these tools are exposed; mutually exclusive with disabled_tools
244+
DisabledTools []string `json:"disabled_tools,omitempty" mapstructure:"disabled_tools"` // Denylist: these tools are hidden; mutually exclusive with enabled_tools
243245
}
244246

245247
// OAuthConfig represents OAuth configuration for a server
@@ -840,6 +842,25 @@ func (sc *ServerConfig) IsQuarantineSkipped() bool {
840842
return sc.SkipQuarantine
841843
}
842844

845+
// IsToolAllowedByConfig reports whether toolName passes the server's static
846+
// enabled_tools / disabled_tools filter. Returns true when neither list is set.
847+
func (sc *ServerConfig) IsToolAllowedByConfig(toolName string) bool {
848+
if len(sc.EnabledTools) > 0 {
849+
for _, t := range sc.EnabledTools {
850+
if t == toolName {
851+
return true
852+
}
853+
}
854+
return false
855+
}
856+
for _, t := range sc.DisabledTools {
857+
if t == toolName {
858+
return false
859+
}
860+
}
861+
return true
862+
}
863+
843864
// EnsureAPIKey ensures the API key is set, generating one if needed
844865
// Returns the API key, whether it was auto-generated, and the source
845866
// SECURITY: Empty API keys are never allowed - always auto-generates if empty or missing
@@ -1009,6 +1030,14 @@ func (c *Config) ValidateDetailed() []ValidationError {
10091030

10101031
// Note: OAuth configuration is optional. client_id is optional (uses Dynamic Client Registration RFC 7591 if empty).
10111032
// ClientSecret can be a secret reference, so we don't validate it as empty.
1033+
1034+
// enabled_tools and disabled_tools are mutually exclusive
1035+
if len(server.EnabledTools) > 0 && len(server.DisabledTools) > 0 {
1036+
errors = append(errors, ValidationError{
1037+
Field: fieldPrefix + ".enabled_tools",
1038+
Message: "enabled_tools and disabled_tools are mutually exclusive; use one or the other",
1039+
})
1040+
}
10121041
}
10131042

10141043
// Validate DataDir exists (if specified and not empty).

internal/config/config_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,3 +1356,26 @@ func TestServerConfig_ReconnectOnUse(t *testing.T) {
13561356
assert.Equal(t, server.ReconnectOnUse, restored.ReconnectOnUse)
13571357
})
13581358
}
1359+
1360+
func TestServerConfig_IsToolAllowedByConfig(t *testing.T) {
1361+
tests := []struct {
1362+
name string
1363+
cfg *ServerConfig
1364+
toolName string
1365+
want bool
1366+
}{
1367+
{"no filter allows everything", &ServerConfig{}, "anything", true},
1368+
{"allowlist: listed tool allowed", &ServerConfig{EnabledTools: []string{"read_file", "list_dir"}}, "read_file", true},
1369+
{"allowlist: unlisted tool denied", &ServerConfig{EnabledTools: []string{"read_file"}}, "delete_file", false},
1370+
{"denylist: listed tool denied", &ServerConfig{DisabledTools: []string{"delete_repo"}}, "delete_repo", false},
1371+
{"denylist: unlisted tool allowed", &ServerConfig{DisabledTools: []string{"delete_repo"}}, "list_repos", true},
1372+
}
1373+
for _, tt := range tests {
1374+
t.Run(tt.name, func(t *testing.T) {
1375+
got := tt.cfg.IsToolAllowedByConfig(tt.toolName)
1376+
if got != tt.want {
1377+
t.Errorf("IsToolAllowedByConfig(%q) = %v, want %v", tt.toolName, got, tt.want)
1378+
}
1379+
})
1380+
}
1381+
}

internal/config/validation_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,64 @@ func TestValidateDetailed(t *testing.T) {
236236
expectedErrors: 0,
237237
errorFields: []string{},
238238
},
239+
{
240+
name: "enabled_tools and disabled_tools are mutually exclusive",
241+
config: &Config{
242+
Listen: ":8080",
243+
ToolsLimit: 15,
244+
ToolResponseLimit: 1000,
245+
CallToolTimeout: Duration(60000000000),
246+
Servers: []*ServerConfig{
247+
{
248+
Name: "test",
249+
Protocol: "http",
250+
URL: "https://api.example.com/mcp",
251+
EnabledTools: []string{"read_file"},
252+
DisabledTools: []string{"write_file"},
253+
},
254+
},
255+
},
256+
expectedErrors: 1,
257+
errorFields: []string{"mcpServers[0].enabled_tools"},
258+
},
259+
{
260+
name: "enabled_tools alone is valid",
261+
config: &Config{
262+
Listen: ":8080",
263+
ToolsLimit: 15,
264+
ToolResponseLimit: 1000,
265+
CallToolTimeout: Duration(60000000000),
266+
Servers: []*ServerConfig{
267+
{
268+
Name: "test",
269+
Protocol: "http",
270+
URL: "https://api.example.com/mcp",
271+
EnabledTools: []string{"read_file", "list_dir"},
272+
},
273+
},
274+
},
275+
expectedErrors: 0,
276+
errorFields: []string{},
277+
},
278+
{
279+
name: "disabled_tools alone is valid",
280+
config: &Config{
281+
Listen: ":8080",
282+
ToolsLimit: 15,
283+
ToolResponseLimit: 1000,
284+
CallToolTimeout: Duration(60000000000),
285+
Servers: []*ServerConfig{
286+
{
287+
Name: "test",
288+
Protocol: "http",
289+
URL: "https://api.example.com/mcp",
290+
DisabledTools: []string{"delete_file", "execute_code"},
291+
},
292+
},
293+
},
294+
expectedErrors: 0,
295+
errorFields: []string{},
296+
},
239297
}
240298

241299
for _, tt := range tests {

internal/contracts/types.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,9 @@ type Tool struct {
195195
// available without a second round-trip to the approvals endpoint. Absent
196196
// in the JSON when false (default) to keep responses compact.
197197
Disabled bool `json:"disabled,omitempty"`
198+
// ConfigDenied is true when the tool is denied by the server's static
199+
// enabled_tools / disabled_tools config. The user cannot override this toggle.
200+
ConfigDenied bool `json:"config_denied,omitempty"`
198201
}
199202

200203
// SearchResult represents a search result for tools

internal/httpapi/server.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2437,6 +2437,12 @@ func (s *Server) handleGetServerTools(w http.ResponseWriter, r *http.Request) {
24372437
// explicitly toggled.
24382438
enrichedCount := 0
24392439
var firstErr error
2440+
2441+
type configDeniedChecker interface {
2442+
IsToolConfigDenied(serverName, toolName string) bool
2443+
}
2444+
configChecker, hasConfigChecker := s.controller.(configDeniedChecker)
2445+
24402446
for i := range typedTools {
24412447
record, err := s.controller.GetToolApproval(serverID, typedTools[i].Name)
24422448
if err == nil && record != nil {
@@ -2446,11 +2452,12 @@ func (s *Server) handleGetServerTools(w http.ResponseWriter, r *http.Request) {
24462452
} else if i == 0 {
24472453
firstErr = err
24482454
}
2455+
if hasConfigChecker {
2456+
typedTools[i].ConfigDenied = configChecker.IsToolConfigDenied(serverID, typedTools[i].Name)
2457+
}
24492458
}
24502459
if firstErr != nil {
2451-
fmt.Printf("[DEBUG] Tool approval enrichment: server=%s enriched=%d/%d first_error=%v\n", serverID, enrichedCount, len(typedTools), firstErr)
2452-
} else {
2453-
fmt.Printf("[DEBUG] Tool approval enrichment: server=%s enriched=%d/%d\n", serverID, enrichedCount, len(typedTools))
2460+
s.logger.Debug("Tool approval enrichment partial", "server", serverID, "enriched", enrichedCount, "total", len(typedTools), "error", firstErr)
24542461
}
24552462

24562463
// Sort: pending/changed tools first, then approved
@@ -4041,6 +4048,17 @@ func (s *Server) handleSetToolEnabled(w http.ResponseWriter, r *http.Request) {
40414048
return
40424049
}
40434050

4051+
// Reject attempts to enable a tool the server config forbids.
4052+
if req.Enabled {
4053+
if configChecker, ok := s.controller.(interface {
4054+
IsToolConfigDenied(serverName, toolName string) bool
4055+
}); ok && configChecker.IsToolConfigDenied(serverID, toolName) {
4056+
s.writeError(w, r, http.StatusConflict,
4057+
"tool is denied by server config (enabled_tools / disabled_tools); remove the config restriction to enable this tool")
4058+
return
4059+
}
4060+
}
4061+
40444062
controller, ok := s.controller.(interface {
40454063
SetToolEnabled(serverName, toolName string, enabled bool, updatedBy string) error
40464064
})

0 commit comments

Comments
 (0)