Skip to content

Commit 3b92de4

Browse files
committed
feat(cli): add 'tools approve|reject' for pending tool quarantine
Add CLI commands to clear tools pending tool-level quarantine (Spec 032) without the Web UI or MCP: mcpproxy tools approve <server:tool>... # approve specific tools mcpproxy tools approve --server <name> --all # approve all pending/changed mcpproxy tools reject <server:tool>... # block = approve+disable mcpproxy tools reject --server <name> --all Targets accept the <server>:<tool> format used elsewhere, or bare tool names scoped via --server. reject maps to the atomic block endpoint (approve+disable), mirroring the Web UI "Block" action. - Add cliclient.BlockTools sibling to ApproveTools (POST /tools/block). - Group targets per server; process independently; -o json|yaml emits a structured per-server result array. - Docs: document both subcommands in cli-management-commands.md. Related #MCP-2919
1 parent c0e569a commit 3b92de4

6 files changed

Lines changed: 523 additions & 0 deletions

File tree

cmd/mcpproxy/tools_approval.go

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"sort"
8+
"strings"
9+
"time"
10+
11+
"github.com/spf13/cobra"
12+
)
13+
14+
var (
15+
// tools approve/reject flags
16+
toolsApprovalServer string // --server scoping
17+
toolsApprovalAll bool // --all (requires --server)
18+
)
19+
20+
func newToolsApproveCmd() *cobra.Command {
21+
cmd := &cobra.Command{
22+
Use: "approve [<server>:<tool>...]",
23+
Short: "Approve tools pending tool-level quarantine (Spec 032)",
24+
Long: `Approve one or more tools that are pending or changed in tool-level
25+
quarantine, clearing them for use without the Web UI or MCP.
26+
27+
Targets may be given as <server>:<tool> pairs, or as bare tool names when
28+
scoped with --server. Use --server <name> --all to approve every pending or
29+
changed tool for a server.
30+
31+
Examples:
32+
mcpproxy tools approve github:create_issue
33+
mcpproxy tools approve github:create_issue github:list_repos
34+
mcpproxy tools approve --server github create_issue list_repos
35+
mcpproxy tools approve --server github --all`,
36+
RunE: func(_ *cobra.Command, args []string) error {
37+
return runToolsApproval(args, false)
38+
},
39+
}
40+
cmd.Flags().StringVarP(&toolsApprovalServer, "server", "s", "", "Scope bare tool names to this server (required with --all)")
41+
cmd.Flags().BoolVar(&toolsApprovalAll, "all", false, "Approve all pending/changed tools for --server")
42+
return cmd
43+
}
44+
45+
func newToolsRejectCmd() *cobra.Command {
46+
cmd := &cobra.Command{
47+
Use: "reject [<server>:<tool>...]",
48+
Short: "Reject (block) tools pending tool-level quarantine (Spec 032)",
49+
Long: `Reject one or more tools pending tool-level quarantine. Reject maps to the
50+
"block" action: the tool is atomically approved AND disabled (hidden), so it is
51+
never left in the approved+enabled state. Mirrors the Web UI "Block" button.
52+
53+
Targets may be given as <server>:<tool> pairs, or as bare tool names when
54+
scoped with --server. Use --server <name> --all to reject every pending or
55+
changed tool for a server.
56+
57+
Examples:
58+
mcpproxy tools reject github:delete_repo
59+
mcpproxy tools reject --server github delete_repo force_push
60+
mcpproxy tools reject --server github --all`,
61+
RunE: func(_ *cobra.Command, args []string) error {
62+
return runToolsApproval(args, true)
63+
},
64+
}
65+
cmd.Flags().StringVarP(&toolsApprovalServer, "server", "s", "", "Scope bare tool names to this server (required with --all)")
66+
cmd.Flags().BoolVar(&toolsApprovalAll, "all", false, "Reject all pending/changed tools for --server")
67+
return cmd
68+
}
69+
70+
// resolveToolApprovalTargets validates approve/reject invocation flags and
71+
// returns the per-server tool grouping.
72+
//
73+
// - With all=true: requires a non-empty server and no positional args, and
74+
// returns {server: nil} with allMode=true (caller issues a single
75+
// approve-all/block-all call for that server).
76+
// - Otherwise: each positional arg is either an explicit "<server>:<tool>"
77+
// pair (the colon form takes precedence) or a bare tool name scoped to the
78+
// --server flag. Bare names without --server are rejected.
79+
func resolveToolApprovalTargets(args []string, server string, all bool) (groups map[string][]string, allMode bool, err error) {
80+
if all {
81+
if server == "" {
82+
return nil, false, fmt.Errorf("--all requires --server <name>")
83+
}
84+
if len(args) > 0 {
85+
return nil, false, fmt.Errorf("--all cannot be combined with explicit <server>:<tool> targets")
86+
}
87+
return map[string][]string{server: nil}, true, nil
88+
}
89+
90+
if len(args) == 0 {
91+
return nil, false, fmt.Errorf("no targets specified: provide <server>:<tool> args, or use --server <name> --all")
92+
}
93+
94+
groups = make(map[string][]string)
95+
for _, arg := range args {
96+
if strings.Contains(arg, ":") {
97+
srv, tool, parseErr := parseServerTool(arg)
98+
if parseErr != nil {
99+
return nil, false, parseErr
100+
}
101+
groups[srv] = append(groups[srv], tool)
102+
continue
103+
}
104+
// Bare tool name — must be scoped via --server.
105+
if server == "" {
106+
return nil, false, fmt.Errorf("invalid target %q: use <server>:<tool> format or scope bare names with --server", arg)
107+
}
108+
groups[server] = append(groups[server], arg)
109+
}
110+
return groups, false, nil
111+
}
112+
113+
// runToolsApproval implements the `tools approve` and `tools reject` commands.
114+
// When block is true it calls the block endpoint (approve+disable); otherwise
115+
// it approves. Each server group is processed independently; the command exits
116+
// non-zero if any group fails but still attempts the rest.
117+
func runToolsApproval(args []string, block bool) error {
118+
groups, allMode, err := resolveToolApprovalTargets(args, toolsApprovalServer, toolsApprovalAll)
119+
if err != nil {
120+
return err
121+
}
122+
123+
client, _, err := newSecurityCLIClient()
124+
if err != nil {
125+
return err
126+
}
127+
128+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
129+
defer cancel()
130+
131+
verb := "approve"
132+
pastTense := "approved"
133+
if block {
134+
verb = "reject"
135+
pastTense = "blocked"
136+
}
137+
138+
// Deterministic ordering for stable output and JSON results.
139+
servers := make([]string, 0, len(groups))
140+
for srv := range groups {
141+
servers = append(servers, srv)
142+
}
143+
sort.Strings(servers)
144+
145+
type serverResult struct {
146+
Server string `json:"server"`
147+
All bool `json:"all,omitempty"`
148+
Tools []string `json:"tools,omitempty"`
149+
Count int `json:"count"`
150+
Error string `json:"error,omitempty"`
151+
}
152+
153+
var results []serverResult
154+
anyFailed := false
155+
for _, srv := range servers {
156+
tools := groups[srv]
157+
var count int
158+
var callErr error
159+
if block {
160+
count, callErr = client.BlockTools(ctx, srv, tools, allMode)
161+
} else {
162+
count, callErr = client.ApproveTools(ctx, srv, tools, allMode)
163+
}
164+
res := serverResult{Server: srv, All: allMode, Tools: tools, Count: count}
165+
if callErr != nil {
166+
anyFailed = true
167+
res.Error = callErr.Error()
168+
}
169+
results = append(results, res)
170+
}
171+
172+
format := ResolveOutputFormat()
173+
if format == "json" || format == "yaml" {
174+
if ferr := formatAndPrint(format, results); ferr != nil {
175+
return ferr
176+
}
177+
if anyFailed {
178+
return fmt.Errorf("one or more servers failed to %s", verb)
179+
}
180+
return nil
181+
}
182+
183+
// Table / human-readable output: one line per server group.
184+
for _, res := range results {
185+
if res.Error != "" {
186+
fmt.Fprintf(os.Stderr, "FAILED %s: %s\n", res.Server, res.Error)
187+
continue
188+
}
189+
scope := fmt.Sprintf("%d tool(s)", res.Count)
190+
if res.All {
191+
scope = fmt.Sprintf("all pending/changed (%d tool(s))", res.Count)
192+
}
193+
fmt.Printf("OK %s: %s %s\n", res.Server, pastTense, scope)
194+
}
195+
196+
if anyFailed {
197+
return fmt.Errorf("one or more servers failed to %s (see above)", verb)
198+
}
199+
return nil
200+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package main
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
// TestResolveToolApprovalTargets_ServerTool verifies that positional
11+
// <server>:<tool> args are parsed and grouped per server.
12+
func TestResolveToolApprovalTargets_ServerTool(t *testing.T) {
13+
groups, allMode, err := resolveToolApprovalTargets(
14+
[]string{"github:create_issue", "github:list_repos", "gitlab:merge"},
15+
"", false)
16+
require.NoError(t, err)
17+
assert.False(t, allMode)
18+
assert.Len(t, groups, 2)
19+
assert.ElementsMatch(t, []string{"create_issue", "list_repos"}, groups["github"])
20+
assert.ElementsMatch(t, []string{"merge"}, groups["gitlab"])
21+
}
22+
23+
// TestResolveToolApprovalTargets_BareToolsWithServer verifies that bare tool
24+
// names are scoped to the --server flag.
25+
func TestResolveToolApprovalTargets_BareToolsWithServer(t *testing.T) {
26+
groups, allMode, err := resolveToolApprovalTargets(
27+
[]string{"create_issue", "list_repos"}, "github", false)
28+
require.NoError(t, err)
29+
assert.False(t, allMode)
30+
assert.Len(t, groups, 1)
31+
assert.ElementsMatch(t, []string{"create_issue", "list_repos"}, groups["github"])
32+
}
33+
34+
// TestResolveToolApprovalTargets_AllRequiresServer verifies that --all without
35+
// --server is rejected.
36+
func TestResolveToolApprovalTargets_AllRequiresServer(t *testing.T) {
37+
_, _, err := resolveToolApprovalTargets(nil, "", true)
38+
require.Error(t, err)
39+
assert.Contains(t, err.Error(), "--server")
40+
}
41+
42+
// TestResolveToolApprovalTargets_All verifies that --all --server yields a
43+
// single server entry in all-mode with an empty tool list.
44+
func TestResolveToolApprovalTargets_All(t *testing.T) {
45+
groups, allMode, err := resolveToolApprovalTargets(nil, "github", true)
46+
require.NoError(t, err)
47+
assert.True(t, allMode)
48+
assert.Len(t, groups, 1)
49+
assert.Empty(t, groups["github"])
50+
}
51+
52+
// TestResolveToolApprovalTargets_AllRejectsPositional verifies that mixing
53+
// positional targets with --all is rejected.
54+
func TestResolveToolApprovalTargets_AllRejectsPositional(t *testing.T) {
55+
_, _, err := resolveToolApprovalTargets([]string{"github:create_issue"}, "github", true)
56+
require.Error(t, err)
57+
}
58+
59+
// TestResolveToolApprovalTargets_NoTargets verifies that an empty invocation
60+
// (no args, no --all) is rejected with guidance.
61+
func TestResolveToolApprovalTargets_NoTargets(t *testing.T) {
62+
_, _, err := resolveToolApprovalTargets(nil, "", false)
63+
require.Error(t, err)
64+
}
65+
66+
// TestResolveToolApprovalTargets_BareToolNoServer verifies that a bare tool
67+
// name without --server (and no colon) is rejected.
68+
func TestResolveToolApprovalTargets_BareToolNoServer(t *testing.T) {
69+
_, _, err := resolveToolApprovalTargets([]string{"create_issue"}, "", false)
70+
require.Error(t, err)
71+
assert.Contains(t, err.Error(), "create_issue")
72+
}
73+
74+
// TestResolveToolApprovalTargets_MixedColonAndServerFlag verifies that explicit
75+
// server:tool args take precedence over the --server flag.
76+
func TestResolveToolApprovalTargets_MixedColonAndServerFlag(t *testing.T) {
77+
groups, _, err := resolveToolApprovalTargets(
78+
[]string{"gitlab:merge", "bare_tool"}, "github", false)
79+
require.NoError(t, err)
80+
assert.ElementsMatch(t, []string{"merge"}, groups["gitlab"])
81+
assert.ElementsMatch(t, []string{"bare_tool"}, groups["github"])
82+
}

cmd/mcpproxy/tools_cmd.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ func init() {
193193
toolsCmd.AddCommand(toolsListCmd)
194194
toolsCmd.AddCommand(toolsEnableCmd)
195195
toolsCmd.AddCommand(toolsDisableCmd)
196+
toolsCmd.AddCommand(newToolsApproveCmd())
197+
toolsCmd.AddCommand(newToolsRejectCmd())
196198

197199
initToolsFlags()
198200
}

docs/cli-management-commands.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,74 @@ echo "exit: $?" # non-zero if any failed
670670

671671
---
672672

673+
### `mcpproxy tools approve [<server:tool>...]`
674+
675+
Approve tools pending tool-level quarantine (Spec 032), clearing `pending` /
676+
`changed` tools for use without the Web UI or MCP. Requires daemon.
677+
678+
**Usage:**
679+
```bash
680+
mcpproxy tools approve <server:tool> [<server:tool>...]
681+
mcpproxy tools approve --server <name> <tool> [<tool>...]
682+
mcpproxy tools approve --server <name> --all
683+
```
684+
685+
**Flags:**
686+
- `-s, --server <name>` - Scope bare tool names to this server (required with `--all`)
687+
- `--all` - Approve every pending/changed tool for `--server`
688+
689+
**Examples:**
690+
```bash
691+
mcpproxy tools approve github:create_issue
692+
mcpproxy tools approve github:create_issue github:list_repos
693+
mcpproxy tools approve --server github create_issue list_repos
694+
mcpproxy tools approve --server github --all
695+
mcpproxy tools approve --server github --all -o json
696+
```
697+
698+
**Behavior:**
699+
- Targets are either `<server>:<tool>` pairs (the colon form wins) or bare tool
700+
names scoped via `--server`; bare names without `--server` are rejected
701+
- Targets are grouped per server; each server group is processed independently
702+
- `--all` requires `--server` and cannot be combined with explicit targets
703+
- Prints `OK <server>: approved N tool(s)` per server group; exits non-zero if
704+
any server group failed
705+
- `-o json|yaml` (or `MCPPROXY_OUTPUT`) emits a structured per-server result array
706+
707+
---
708+
709+
### `mcpproxy tools reject [<server:tool>...]`
710+
711+
Reject (block) tools pending tool-level quarantine (Spec 032). Reject maps to
712+
the **block** action: the tool is atomically approved **and** disabled (hidden),
713+
so it is never left in the approved+enabled state — mirroring the Web UI "Block"
714+
button. Requires daemon.
715+
716+
**Usage:**
717+
```bash
718+
mcpproxy tools reject <server:tool> [<server:tool>...]
719+
mcpproxy tools reject --server <name> <tool> [<tool>...]
720+
mcpproxy tools reject --server <name> --all
721+
```
722+
723+
**Flags:**
724+
- `-s, --server <name>` - Scope bare tool names to this server (required with `--all`)
725+
- `--all` - Reject every pending/changed tool for `--server`
726+
727+
**Examples:**
728+
```bash
729+
mcpproxy tools reject github:delete_repo
730+
mcpproxy tools reject --server github delete_repo force_push
731+
mcpproxy tools reject --server github --all
732+
```
733+
734+
**Behavior:**
735+
- Same target parsing, per-server grouping, exit-code, and output-format
736+
semantics as `tools approve`
737+
- Prints `OK <server>: blocked N tool(s)` per server group
738+
739+
---
740+
673741
## Exit Codes
674742

675743
- `0` - Success

0 commit comments

Comments
 (0)