Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions frontend/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,15 @@ export interface Tool {
name: string
description: string
server: string
server_name?: string
input_schema?: Record<string, any>
schema?: Record<string, any>
annotations?: ToolAnnotation
usage?: number
last_used?: string
approval_status?: string
disabled?: boolean
config_denied?: boolean
}

// Tool approval types (Spec 032)
Expand Down
42 changes: 37 additions & 5 deletions frontend/src/views/ServerDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,14 @@
</div>
</div>
</div>
<template v-if="isToolConfigDenied(tool.tool_name)">
<span
class="badge badge-neutral badge-sm ml-4 self-center"
title="Tool is denied by mcp_config.json; approval has no effect while the config lock is active"
>🔒 locked by config</span>
</template>
<button
v-else
@click="approveTool(tool.tool_name)"
:disabled="approvalLoading"
class="btn btn-sm btn-outline ml-4"
Expand Down Expand Up @@ -487,6 +494,11 @@
v-else-if="getToolApprovalStatus(tool.name) === 'changed'"
class="badge badge-warning badge-sm"
>changed</span>
<span
v-if="isToolConfigDenied(tool.name)"
class="badge badge-neutral badge-sm"
title="Disabled by mcp_config.json (enabled_tools / disabled_tools)"
>🔒 locked by config</span>
</div>
<label
v-if="isToolToggleAvailable(tool.name)"
Expand All @@ -504,6 +516,11 @@
@change="toggleToolEnabled(tool.name, ($event.target as HTMLInputElement).checked)"
/>
</label>
<span
v-else-if="isToolConfigDenied(tool.name)"
class="text-xs text-base-content/40 shrink-0 italic"
title="Remove from disabled_tools or add to enabled_tools in mcp_config.json to unlock"
>🔒 locked by config</span>
</div>
<div
class="transition-opacity"
Expand Down Expand Up @@ -1389,16 +1406,20 @@ function getToolApproval(toolName: string): ToolApproval | null {
return toolApprovals.value.find(t => t.tool_name === toolName) || null
}

function isToolConfigDenied(toolName: string): boolean {
const tool = serverTools.value.find(t => t.name === toolName)
return tool?.config_denied === true
}

function isToolEnabled(toolName: string): boolean {
// GET /api/v1/servers/{id}/tools returns each tool with a top-level
// `disabled` boolean (see contracts.Tool.Disabled in Go) when an approval
// record exists. The approvals endpoint also exposes `enabled`/`disabled`.
// Cross-check both so the toggle reflects reality regardless of which
// payload the frontend already loaded.
const tool = serverTools.value.find(t => t.name === toolName) as Tool & { disabled?: boolean; enabled?: boolean } | undefined
const tool = serverTools.value.find(t => t.name === toolName)
if (tool) {
if (typeof tool.disabled === 'boolean') return !tool.disabled
if (typeof tool.enabled === 'boolean') return tool.enabled
}
const approval = getToolApproval(toolName)
if (!approval) return true
Expand All @@ -1416,6 +1437,7 @@ function isToolToggleLoading(toolName: string): boolean {
// tools the daemon synthesizes an approval record on demand, so the toggle
// works in every other case.
function isToolToggleAvailable(toolName: string): boolean {
if (isToolConfigDenied(toolName)) return false
const status = getToolApprovalStatus(toolName)
return status === null || status === 'approved'
}
Expand Down Expand Up @@ -1921,12 +1943,22 @@ async function bulkToggleAllTools(enabled: boolean) {
const response = await api.setAllToolsEnabled(server.value.name, enabled)
if (response.success && response.data) {
const changed = response.data.changed ?? 0
// "Enable All" intentionally skips tools the server config denies
// (enabled_tools/disabled_tools) — surface that so the user isn't
// left wondering why some toggles stayed locked.
const lockedByConfig = enabled
? serverTools.value.filter(t => t.config_denied === true).length
: 0
const baseMsg = changed === 0
? 'No tools needed changes.'
: `${changed} tool${changed === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}.`
const lockedMsg = lockedByConfig > 0
? ` ${lockedByConfig} tool${lockedByConfig === 1 ? '' : 's'} remain locked by config.`
: ''
systemStore.addToast({
type: 'success',
title: enabled ? 'Tools Enabled' : 'Tools Disabled',
message: changed === 0
? 'No tools needed changes.'
: `${changed} tool${changed === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}.`,
message: baseMsg + lockedMsg,
})
// Refresh server data + tool caches so the per-tool toggle, the
// "N disabled" pill, and the Server List both lose any staleness.
Expand Down
31 changes: 30 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,9 @@ type ServerConfig struct {
// when the server is configured with both Command and an HTTP/SSE URL — i.e.,
// mcpproxy starts the process AND connects via network. Stdio servers ignore
// this field. Zero or unset → 30s default.
LauncherWaitTimeout Duration `json:"launcher_wait_timeout,omitempty" mapstructure:"launcher_wait_timeout" swaggertype:"string"`
LauncherWaitTimeout Duration `json:"launcher_wait_timeout,omitempty" mapstructure:"launcher_wait_timeout" swaggertype:"string"`
EnabledTools []string `json:"enabled_tools,omitempty" mapstructure:"enabled_tools"` // Allowlist: only these tools are exposed; mutually exclusive with disabled_tools
DisabledTools []string `json:"disabled_tools,omitempty" mapstructure:"disabled_tools"` // Denylist: these tools are hidden; mutually exclusive with enabled_tools
}

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

// IsToolAllowedByConfig reports whether toolName passes the server's static
// enabled_tools / disabled_tools filter. Returns true when neither list is set.
func (sc *ServerConfig) IsToolAllowedByConfig(toolName string) bool {
if len(sc.EnabledTools) > 0 {
for _, t := range sc.EnabledTools {
if t == toolName {
return true
}
}
return false
}
for _, t := range sc.DisabledTools {
if t == toolName {
return false
}
}
return true
}

// EnsureAPIKey ensures the API key is set, generating one if needed
// Returns the API key, whether it was auto-generated, and the source
// SECURITY: Empty API keys are never allowed - always auto-generates if empty or missing
Expand Down Expand Up @@ -1009,6 +1030,14 @@ func (c *Config) ValidateDetailed() []ValidationError {

// Note: OAuth configuration is optional. client_id is optional (uses Dynamic Client Registration RFC 7591 if empty).
// ClientSecret can be a secret reference, so we don't validate it as empty.

// enabled_tools and disabled_tools are mutually exclusive
if len(server.EnabledTools) > 0 && len(server.DisabledTools) > 0 {
errors = append(errors, ValidationError{
Field: fieldPrefix + ".enabled_tools",
Message: "enabled_tools and disabled_tools are mutually exclusive; use one or the other",
})
}
}

// Validate DataDir exists (if specified and not empty).
Expand Down
23 changes: 23 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1356,3 +1356,26 @@ func TestServerConfig_ReconnectOnUse(t *testing.T) {
assert.Equal(t, server.ReconnectOnUse, restored.ReconnectOnUse)
})
}

func TestServerConfig_IsToolAllowedByConfig(t *testing.T) {
tests := []struct {
name string
cfg *ServerConfig
toolName string
want bool
}{
{"no filter allows everything", &ServerConfig{}, "anything", true},
{"allowlist: listed tool allowed", &ServerConfig{EnabledTools: []string{"read_file", "list_dir"}}, "read_file", true},
{"allowlist: unlisted tool denied", &ServerConfig{EnabledTools: []string{"read_file"}}, "delete_file", false},
{"denylist: listed tool denied", &ServerConfig{DisabledTools: []string{"delete_repo"}}, "delete_repo", false},
{"denylist: unlisted tool allowed", &ServerConfig{DisabledTools: []string{"delete_repo"}}, "list_repos", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.cfg.IsToolAllowedByConfig(tt.toolName)
if got != tt.want {
t.Errorf("IsToolAllowedByConfig(%q) = %v, want %v", tt.toolName, got, tt.want)
}
})
}
}
58 changes: 58 additions & 0 deletions internal/config/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,64 @@ func TestValidateDetailed(t *testing.T) {
expectedErrors: 0,
errorFields: []string{},
},
{
name: "enabled_tools and disabled_tools are mutually exclusive",
config: &Config{
Listen: ":8080",
ToolsLimit: 15,
ToolResponseLimit: 1000,
CallToolTimeout: Duration(60000000000),
Servers: []*ServerConfig{
{
Name: "test",
Protocol: "http",
URL: "https://api.example.com/mcp",
EnabledTools: []string{"read_file"},
DisabledTools: []string{"write_file"},
},
},
},
expectedErrors: 1,
errorFields: []string{"mcpServers[0].enabled_tools"},
},
{
name: "enabled_tools alone is valid",
config: &Config{
Listen: ":8080",
ToolsLimit: 15,
ToolResponseLimit: 1000,
CallToolTimeout: Duration(60000000000),
Servers: []*ServerConfig{
{
Name: "test",
Protocol: "http",
URL: "https://api.example.com/mcp",
EnabledTools: []string{"read_file", "list_dir"},
},
},
},
expectedErrors: 0,
errorFields: []string{},
},
{
name: "disabled_tools alone is valid",
config: &Config{
Listen: ":8080",
ToolsLimit: 15,
ToolResponseLimit: 1000,
CallToolTimeout: Duration(60000000000),
Servers: []*ServerConfig{
{
Name: "test",
Protocol: "http",
URL: "https://api.example.com/mcp",
DisabledTools: []string{"delete_file", "execute_code"},
},
},
},
expectedErrors: 0,
errorFields: []string{},
},
}

for _, tt := range tests {
Expand Down
3 changes: 3 additions & 0 deletions internal/contracts/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,9 @@ type Tool struct {
// available without a second round-trip to the approvals endpoint. Absent
// in the JSON when false (default) to keep responses compact.
Disabled bool `json:"disabled,omitempty"`
// ConfigDenied is true when the tool is denied by the server's static
// enabled_tools / disabled_tools config. The user cannot override this toggle.
ConfigDenied bool `json:"config_denied,omitempty"`
}

// SearchResult represents a search result for tools
Expand Down
24 changes: 21 additions & 3 deletions internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2437,6 +2437,12 @@ func (s *Server) handleGetServerTools(w http.ResponseWriter, r *http.Request) {
// explicitly toggled.
enrichedCount := 0
var firstErr error

type configDeniedChecker interface {
IsToolConfigDenied(serverName, toolName string) bool
}
configChecker, hasConfigChecker := s.controller.(configDeniedChecker)

for i := range typedTools {
record, err := s.controller.GetToolApproval(serverID, typedTools[i].Name)
if err == nil && record != nil {
Expand All @@ -2446,11 +2452,12 @@ func (s *Server) handleGetServerTools(w http.ResponseWriter, r *http.Request) {
} else if i == 0 {
firstErr = err
}
if hasConfigChecker {
typedTools[i].ConfigDenied = configChecker.IsToolConfigDenied(serverID, typedTools[i].Name)
}
}
if firstErr != nil {
fmt.Printf("[DEBUG] Tool approval enrichment: server=%s enriched=%d/%d first_error=%v\n", serverID, enrichedCount, len(typedTools), firstErr)
} else {
fmt.Printf("[DEBUG] Tool approval enrichment: server=%s enriched=%d/%d\n", serverID, enrichedCount, len(typedTools))
s.logger.Debug("Tool approval enrichment partial", "server", serverID, "enriched", enrichedCount, "total", len(typedTools), "error", firstErr)
}

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

// Reject attempts to enable a tool the server config forbids.
if req.Enabled {
if configChecker, ok := s.controller.(interface {
IsToolConfigDenied(serverName, toolName string) bool
}); ok && configChecker.IsToolConfigDenied(serverID, toolName) {
s.writeError(w, r, http.StatusConflict,
"tool is denied by server config (enabled_tools / disabled_tools); remove the config restriction to enable this tool")
return
}
}

controller, ok := s.controller.(interface {
SetToolEnabled(serverName, toolName string, enabled bool, updatedBy string) error
})
Expand Down
Loading
Loading