Skip to content

Commit 7aa683b

Browse files
committed
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>
1 parent 3f24ce3 commit 7aa683b

3 files changed

Lines changed: 61 additions & 10 deletions

File tree

frontend/src/views/ServerDetail.vue

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -390,9 +390,9 @@
390390
</div>
391391
<template v-if="isToolConfigDenied(tool.tool_name)">
392392
<span
393-
class="badge badge-error badge-sm ml-4 self-center"
393+
class="badge badge-neutral badge-sm ml-4 self-center"
394394
title="Tool is denied by mcp_config.json; approval has no effect while the config lock is active"
395-
>locked by config</span>
395+
>🔒 locked by config</span>
396396
</template>
397397
<button
398398
v-else
@@ -496,9 +496,9 @@
496496
>changed</span>
497497
<span
498498
v-if="isToolConfigDenied(tool.name)"
499-
class="badge badge-error badge-sm"
499+
class="badge badge-neutral badge-sm"
500500
title="Disabled by mcp_config.json (enabled_tools / disabled_tools)"
501-
>locked by config</span>
501+
>🔒 locked by config</span>
502502
</div>
503503
<label
504504
v-if="isToolToggleAvailable(tool.name)"
@@ -520,7 +520,7 @@
520520
v-else-if="isToolConfigDenied(tool.name)"
521521
class="text-xs text-base-content/40 shrink-0 italic"
522522
title="Remove from disabled_tools or add to enabled_tools in mcp_config.json to unlock"
523-
>config locked</span>
523+
>🔒 locked by config</span>
524524
</div>
525525
<div
526526
class="transition-opacity"
@@ -1943,12 +1943,22 @@ async function bulkToggleAllTools(enabled: boolean) {
19431943
const response = await api.setAllToolsEnabled(server.value.name, enabled)
19441944
if (response.success && response.data) {
19451945
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+
: ''
19461958
systemStore.addToast({
19471959
type: 'success',
19481960
title: enabled ? 'Tools Enabled' : 'Tools Disabled',
1949-
message: changed === 0
1950-
? 'No tools needed changes.'
1951-
: `${changed} tool${changed === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}.`,
1961+
message: baseMsg + lockedMsg,
19521962
})
19531963
// Refresh server data + tool caches so the per-tool toggle, the
19541964
// "N disabled" pill, and the Server List both lose any staleness.

internal/server/mcp.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1547,7 +1547,7 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
15471547
}
15481548

15491549
if !p.isToolCallable(serverName, actualToolName) {
1550-
errMsg := "TOOL_BLOCKED: Tool is disabled and not callable."
1550+
errMsg := p.blockedToolMessage(serverName, actualToolName)
15511551
p.emitActivityPolicyDecision(serverName, actualToolName, sessionID, "blocked", errMsg)
15521552
return mcp.NewToolResultError(errMsg), nil
15531553
}
@@ -1913,7 +1913,7 @@ func (p *MCPProxyServer) handleCallTool(ctx context.Context, request mcp.CallToo
19131913
zap.String("server_name", serverName))
19141914

19151915
if !p.isToolCallable(serverName, actualToolName) {
1916-
errMsg := "TOOL_BLOCKED: Tool is disabled and not callable."
1916+
errMsg := p.blockedToolMessage(serverName, actualToolName)
19171917
p.emitActivityPolicyDecision(serverName, actualToolName, sessionID, "blocked", errMsg)
19181918
return mcp.NewToolResultError(errMsg), nil
19191919
}
@@ -4604,6 +4604,31 @@ func (p *MCPProxyServer) isToolCallable(serverName, toolName string) bool {
46044604
return true
46054605
}
46064606

4607+
// blockedToolMessage returns an agent-actionable reason a tool is not callable.
4608+
// It distinguishes operator config policy (enabled_tools/disabled_tools — NOT
4609+
// user-overridable from the UI; a UI/API enable attempt 409s) from a user or
4610+
// runtime disable, so an agent relays the correct remediation instead of
4611+
// telling the user to toggle a switch that cannot lift the lock.
4612+
func (p *MCPProxyServer) blockedToolMessage(serverName, toolName string) string {
4613+
configDenied := p.mainServer != nil && p.mainServer.runtime != nil &&
4614+
p.mainServer.runtime.IsToolConfigDenied(serverName, toolName)
4615+
return blockedToolMessageFor(configDenied)
4616+
}
4617+
4618+
// blockedToolMessageFor is the pure message-selection half of
4619+
// blockedToolMessage, split out so the operator-policy vs user-disable wording
4620+
// is unit-testable without standing up a runtime.
4621+
func blockedToolMessageFor(configDenied bool) string {
4622+
if configDenied {
4623+
return "TOOL_BLOCKED: Tool is denied by server config (enabled_tools/disabled_tools). " +
4624+
"This is operator policy and is NOT user-overridable from the UI; " +
4625+
"ask the operator to edit mcp_config.json to enable it."
4626+
}
4627+
return "TOOL_BLOCKED: Tool is disabled and not callable. " +
4628+
"It may be disabled by the user or pending security approval; " +
4629+
"ask the user to enable it in the mcpproxy UI (Server detail → Tools)."
4630+
}
4631+
46074632
func (p *MCPProxyServer) lookupToolAnnotations(serverName, toolName string) *config.ToolAnnotations {
46084633
if p.mainServer == nil || p.mainServer.runtime == nil {
46094634
return nil

internal/server/mcp_blocked_tools_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,3 +188,19 @@ func TestIsToolCallable_FailsClosedOnStorageError(t *testing.T) {
188188
assert.False(t, proxy.isToolCallable("badstore", "fragile-tool"),
189189
"isToolCallable must be fail-closed on storage decode errors")
190190
}
191+
192+
func TestBlockedToolMessageFor(t *testing.T) {
193+
cfgDenied := blockedToolMessageFor(true)
194+
assert.Contains(t, cfgDenied, "TOOL_BLOCKED")
195+
assert.Contains(t, cfgDenied, "enabled_tools/disabled_tools")
196+
assert.Contains(t, cfgDenied, "NOT user-overridable")
197+
assert.Contains(t, cfgDenied, "mcp_config.json")
198+
// Must NOT tell the user to flip a UI toggle — that 409s for config-denied.
199+
assert.NotContains(t, cfgDenied, "enable it in the mcpproxy UI")
200+
201+
userDisabled := blockedToolMessageFor(false)
202+
assert.Contains(t, userDisabled, "TOOL_BLOCKED")
203+
// Preserves the legacy substring existing tests/agents key off of.
204+
assert.Contains(t, userDisabled, "Tool is disabled and not callable.")
205+
assert.Contains(t, userDisabled, "enable it in the mcpproxy UI")
206+
}

0 commit comments

Comments
 (0)