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
143 changes: 143 additions & 0 deletions cmd/mcpproxy/upstream_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,48 @@ Examples:
RunE: runUpstreamApprove,
}

// Per-tool enable/disable. The "tools" subcommand groups the four
// operations so the surface mirrors the server-level
// "upstream enable/disable" + "upstream enable --all/--all" without
// shadowing those flags.
upstreamToolsCmd = &cobra.Command{
Use: "tools",
Short: "Manage per-tool enable/disable state for a server",
Long: `Enable or disable individual tools (or all tools) of an upstream server.

Disabled tools are filtered out of retrieve_tools results and rejected on
direct call_tool_* invocations. Use this to suppress noisy or unused tools
without removing the whole server.`,
}

upstreamToolsEnableCmd = &cobra.Command{
Use: "enable <server-name> <tool-name>",
Short: "Enable a tool for a server",
Args: cobra.ExactArgs(2),
RunE: func(_ *cobra.Command, args []string) error { return runUpstreamToolAction(args[0], args[1], true) },
}

upstreamToolsDisableCmd = &cobra.Command{
Use: "disable <server-name> <tool-name>",
Short: "Disable a tool for a server",
Args: cobra.ExactArgs(2),
RunE: func(_ *cobra.Command, args []string) error { return runUpstreamToolAction(args[0], args[1], false) },
}

upstreamToolsEnableAllCmd = &cobra.Command{
Use: "enable-all <server-name>",
Short: "Enable every tool for a server",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error { return runUpstreamToolBulkAction(args[0], true) },
}

upstreamToolsDisableAllCmd = &cobra.Command{
Use: "disable-all <server-name>",
Short: "Disable every tool for a server",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error { return runUpstreamToolBulkAction(args[0], false) },
}

upstreamImportCmd = &cobra.Command{
Use: "import <path>",
Short: "Import servers from external configuration file",
Expand Down Expand Up @@ -233,6 +275,11 @@ func init() {
upstreamCmd.AddCommand(upstreamInspectCmd)
upstreamCmd.AddCommand(upstreamApproveCmd)
upstreamCmd.AddCommand(upstreamImportCmd)
upstreamCmd.AddCommand(upstreamToolsCmd)
upstreamToolsCmd.AddCommand(upstreamToolsEnableCmd)
upstreamToolsCmd.AddCommand(upstreamToolsDisableCmd)
upstreamToolsCmd.AddCommand(upstreamToolsEnableAllCmd)
upstreamToolsCmd.AddCommand(upstreamToolsDisableAllCmd)

// Define flags (note: output format handled by global --output/-o flag from root command)
upstreamListCmd.Flags().StringVarP(&upstreamLogLevel, "log-level", "l", "warn", "Log level (trace, debug, info, warn, error)")
Expand Down Expand Up @@ -863,6 +910,102 @@ func runUpstreamAction(serverName, action string) error {
return nil
}

// runUpstreamToolAction toggles a single tool for a server via the daemon.
// The action verb ("enable"/"disable") is derived from `enabled` so the
// surface stays narrow.
func runUpstreamToolAction(serverName, toolName string, enabled bool) error {
verb := "enable"
if !enabled {
verb = "disable"
}

ctx := reqcontext.WithMetadata(context.Background(), reqcontext.SourceCLI)
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

globalConfig, err := loadUpstreamConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading configuration: %v\n", err)
return err
}
if err := validateServerExists(globalConfig, serverName); err != nil {
return err
}

logger, err := createUpstreamLogger(upstreamLogLevel)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating logger: %v\n", err)
return err
}

if !shouldUseUpstreamDaemon(globalConfig.DataDir) {
return fmt.Errorf("tool actions require running daemon. Start with: mcpproxy serve")
}

socketPath := socket.DetectSocketPath(globalConfig.DataDir)
client := cliclient.NewClient(socketPath, logger.Sugar())

// "enable" / "disable" are ASCII verbs, so an inline ASCII-only
// title-case is fine here and avoids the deprecated strings.Title.
titleVerb := strings.ToUpper(verb[:1]) + verb[1:]
fmt.Printf("%sing tool '%s' on server '%s'...\n", titleVerb, toolName, serverName)
if err := client.SetToolEnabled(ctx, serverName, toolName, enabled); err != nil {
return fmt.Errorf("failed to %s tool: %w", verb, err)
}
fmt.Printf("✅ Tool '%s' %sd on server '%s'\n", toolName, verb, serverName)
return nil
}

// runUpstreamToolBulkAction enables or disables every known tool for a server.
func runUpstreamToolBulkAction(serverName string, enabled bool) error {
verb := "enable-all"
if !enabled {
verb = "disable-all"
}

ctx := reqcontext.WithMetadata(context.Background(), reqcontext.SourceCLI)
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()

globalConfig, err := loadUpstreamConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading configuration: %v\n", err)
return err
}
if err := validateServerExists(globalConfig, serverName); err != nil {
return err
}

logger, err := createUpstreamLogger(upstreamLogLevel)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating logger: %v\n", err)
return err
}

if !shouldUseUpstreamDaemon(globalConfig.DataDir) {
return fmt.Errorf("tool actions require running daemon. Start with: mcpproxy serve")
}

socketPath := socket.DetectSocketPath(globalConfig.DataDir)
client := cliclient.NewClient(socketPath, logger.Sugar())

fmt.Printf("Running tools %s on server '%s'...\n", verb, serverName)
changed, err := client.SetAllToolsEnabled(ctx, serverName, enabled)
if err != nil {
return fmt.Errorf("failed to %s tools: %w", verb, err)
}
if changed == 0 {
fmt.Printf("ℹ️ No tools changed (already in target state) on server '%s'\n", serverName)
return nil
}
state := "enabled"
if !enabled {
state = "disabled"
}
fmt.Printf("✅ %d tool(s) %s on server '%s'\n", changed, state, serverName)
return nil
}

// T081-T082: Updated to use new bulk operation endpoints
func runUpstreamBulkAction(action string, force bool) error {
// Create context with correlation ID and request source tracking
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ServerCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
<svg class="w-3 h-3 inline-block flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ blockedToolCount }} blocked
{{ blockedToolCount }} disabled
</div>
<div v-else-if="quarantineToolCount > 0" class="stat-desc text-xs text-warning flex items-center gap-1">
<svg class="w-3 h-3 inline-block flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,20 @@ class APIService {
})
}

// Bulk-toggle every known tool of a server. The response's `changed`
// field reflects only tools whose state actually changed — already-correct
// tools are skipped on the server side.
async setAllToolsEnabled(serverName: string, enabled: boolean): Promise<APIResponse<{
server_name: string
enabled: boolean
changed: number
}>> {
const action = enabled ? 'enable_all' : 'disable_all'
return this.request<{ server_name: string; enabled: boolean; changed: number }>(`/api/v1/servers/${encodeURIComponent(serverName)}/tools/${action}`, {
method: 'POST',
})
}

async getServerLogs(serverName: string, tail?: number): Promise<APIResponse<{ logs: string[] }>> {
const params = tail ? `?tail=${tail}` : ''
return this.request<{ logs: string[] }>(`/api/v1/servers/${encodeURIComponent(serverName)}/logs${params}`)
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/types/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ export interface Tool {
schema?: Record<string, any>;
usage: number;
last_used?: string; // ISO date string
// Mirrors contracts.Tool.Disabled on the Go side — present when an
// approval record exists for this tool. Absent means "enabled" (default).
disabled?: boolean;
// Tool-level quarantine status surfaced by the same approval record.
// Optional because non-quarantined tools simply omit the field.
approval_status?: string;
}

export interface SearchResult {
Expand Down
Loading
Loading