fix(studio): unify MCP launcher name inference

This commit is contained in:
xiexian committed 2026-10-04 06:38:46 +08:00
1 parent 875dd8e67a
commit a078f8e30f
16 files changed
+1000 -132

No files matched your search

+10 -94
View File
@@ -146,27 +146,19 @@ func NameFromURL(raw string) string {
// NameFromArgv derives a server name from the command that starts it, looking
// through the runner (npx/uvx/python -m/…) to the package it actually launches.
func NameFromArgv(command string, args []string) string {
runner := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSuffix(strings.ToLower(filepath.Base(command)), ".exe"), ".cmd"), ".bat")
runner := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSuffix(strings.ToLower(filepath.Base(strings.ReplaceAll(command, "\\", "/"))), ".exe"), ".cmd"), ".bat")
candidate := command
switch runner {
case "npx", "bunx":
if operand := firstCommandOperand(args); operand != "" {
candidate = operand
}
case "python", "python3", "py":
if operand := pythonCommandOperand(args); operand != "" {
candidate = operand
}
case "node":
if operand := nodeCommandOperand(args); operand != "" {
candidate = operand
}
case "uv", "uvx":
if operand := uvCommandOperand(args, runner == "uv"); operand != "" {
candidate = operand
launcher, known := commandLaunchers[runner]
if known {
candidate = launcherCommandOperand(args, launcher)
if candidate == "" {
return "mcp-server"
}
}
base := filepath.Base(candidate)
base := filepath.Base(strings.ReplaceAll(candidate, "\\", "/"))
if known && launcher.image {
base, _, _ = strings.Cut(base, ":")
}
if runner == "uvx" {
base, _, _ = strings.Cut(base, "==")
}
@@ -180,85 +172,9 @@ func NameFromArgv(command string, args []string) string {
if name == "" {
return "mcp-server"
}
if candidate == command {
switch runner {
case "npx", "bunx", "uvx", "uv", "node", "python", "python3", "py":
return "mcp-server"
}
}
return name
}
func pythonCommandOperand(args []string) string {
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--" || arg == "-m":
if i+1 < len(args) {
return args[i+1]
}
return ""
case arg == "-" || strings.HasPrefix(arg, "-c"):
return ""
case strings.HasPrefix(arg, "-m"):
return strings.TrimPrefix(arg, "-m")
case arg == "-W" || arg == "-X" || arg == "--check-hash-based-pycs":
i++
case arg != "" && !strings.HasPrefix(arg, "-"):
return arg
}
}
return ""
}
func firstCommandOperand(args []string) string {
valueFlags := map[string]bool{
"-p": true, "--package": true, "-c": true, "--call": true,
"--node-options": true, "--python": true, "--from": true,
"-w": true, "--with": true, "--with-requirements": true,
}
options := true
for i := 0; i < len(args); i++ {
arg := strings.TrimSpace(args[i])
if options && arg == "--" {
options = false
continue
}
if options && strings.HasPrefix(arg, "-") {
if valueFlags[arg] {
i++
}
continue
}
if arg != "" {
return arg
}
}
return ""
}
func nodeCommandOperand(args []string) string {
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--":
if i+1 < len(args) {
return args[i+1]
}
return ""
case arg == "-", arg == "-e", arg == "--eval", arg == "-p", arg == "--print",
strings.HasPrefix(arg, "--eval="), strings.HasPrefix(arg, "--print="):
return ""
case arg == "-r", arg == "--require", arg == "--import", arg == "--env-file",
arg == "--env-file-if-exists", arg == "--conditions", arg == "-C":
i++
case arg != "" && !strings.HasPrefix(arg, "-"):
return arg
}
}
return ""
}
// SanitizeName reduces a derived name to the lowercase-and-dashes form config
// and tool prefixes accept.
func SanitizeName(raw string) string {
@@ -0,0 +1,62 @@
package mcpsetup
import (
"reflect"
"strings"
"testing"
)
func TestDockerServerNameUsesImageOperand(t *testing.T) {
for _, tc := range []struct {
args []string
name string
}{
{[]string{"run", "--rm", "-i", "mcp/time"}, "time"},
{[]string{"run", "--rm", "-it", "mcp/fetch:latest"}, "fetch"},
{[]string{"container", "run", "--rm", "-i", "mcp/time"}, "time"},
{[]string{"run", "--rm=false", "--interactive=true", "mcp/time:1.2.3"}, "time"},
{[]string{"run", "-e", "MODE=test", "--env-file", "config.env", "-v", "/data:/data", "mcp/filesystem", "/data"}, "filesystem"},
{[]string{"run", "--env=MODE=test", "--volume=/data:/data", "mcp/filesystem", "/data"}, "filesystem"},
{[]string{"run", "-eMODE=test", "-v/data:/data", "mcp/filesystem", "/data"}, "filesystem"},
{[]string{"run", "--name", "worker", "--network", "none", "--platform", "linux/amd64", "registry.example:5000/tools/time:stable"}, "time"},
{[]string{"run", "--mount", "type=bind,src=/data,dst=/data", "--entrypoint", "python", "--workdir", "/app", "--user", "1000", "mcp/time", "-m", "server"}, "time"},
{[]string{"run", "--", "mcp/time@sha256:" + strings.Repeat("0123456789abcdef", 4), "--env", "server-value"}, "time"},
{[]string{"run", "--pull", "never", "--read-only", "--init", "mcp/time"}, "time"},
{[]string{"run", "mcp/time", "mcp/fetch"}, "time"},
{[]string{"run", "--rm", "-i"}, "mcp-server"},
{[]string{"run", "--env"}, "mcp-server"},
{[]string{"run", "--unknown", "option-value", "mcp/time"}, "mcp-server"},
{[]string{"version"}, "mcp-server"},
} {
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
commands := []string{"docker", "/usr/local/bin/docker", "DOCKER.EXE"}
for _, command := range commands {
if got := NameFromArgv(command, tc.args); got != tc.name {
t.Errorf("%s name = %q, want %q", command, got, tc.name)
}
entry, err := ParseArgs(append([]string{"--", command}, tc.args...))
if err != nil || entry.Name != tc.name || entry.Command != command || !reflect.DeepEqual(entry.Args, tc.args) {
t.Errorf("CLI entry = %+v, err=%v", entry, err)
}
explicit, err := ParseArgs(append([]string{"manual", "--", command}, tc.args...))
if err != nil || explicit.Name != "manual" || !reflect.DeepEqual(explicit.Args, tc.args) {
t.Errorf("explicit entry = %+v, err=%v", explicit, err)
}
draft, err := Parse(command + " " + strings.Join(tc.args, " "))
if err != nil || len(draft.Entries) != 1 || draft.Entries[0].Name != tc.name || !reflect.DeepEqual(draft.Entries[0].Args, tc.args) {
t.Errorf("pasted draft = %+v, err=%v", draft, err)
}
}
})
}
draft, err := Parse(`{"mcpServers":{"manual":{"command":"docker","args":["run","--rm","-i","mcp/time"]}}}`)
if err != nil || len(draft.Entries) != 1 || draft.Entries[0].Name != "manual" {
t.Errorf("explicit JSON name = %+v, err=%v", draft, err)
}
if got := NameFromArgv("npx", []string{"-y", "@modelcontextprotocol/server-time@latest"}); got != "server-time" {
t.Errorf("npx control = %q", got)
}
if got := NameFromArgv("node", []string{"--require", "./preload.cjs", "time.js"}); got != "time" {
t.Errorf("Node control = %q", got)
}
}
+90
View File
@@ -0,0 +1,90 @@
package mcpsetup
import (
"reflect"
"strings"
"testing"
)
func TestGoServerNameUsesRunPackage(t *testing.T) {
for _, tc := range []struct {
command string
args []string
name string
}{
{"go", []string{"run", "example.com/tools/reasonix-server@latest", "--stdio"}, "reasonix-server"},
{"go", []string{"run", "example.com/tools/[email protected]"}, "reasonix-server"},
{"/usr/local/go/bin/go", []string{"run", "./cmd/reasonix-server", "client-mode"}, "reasonix-server"},
{"GO.EXE", []string{"run", "./cmd/reasonix-server"}, "reasonix-server"},
{"go", []string{"-C", "workspace", "run", "./cmd/reasonix-server"}, "reasonix-server"},
{"go", []string{"-C=workspace", "run", "./cmd/reasonix-server"}, "reasonix-server"},
{"go", []string{"run", "-C", "workspace", "./cmd/reasonix-server"}, "reasonix-server"},
{"go", []string{"run", "-race", "-trimpath", "./cmd/reasonix-server"}, "reasonix-server"},
{"go", []string{"run", "--", "./cmd/reasonix-server", "-tags", "client-mode"}, "reasonix-server"},
{"go", []string{"run", "./cmd/reasonix-server", "-exec", "client-mode"}, "reasonix-server"},
{"go", []string{"run", "./reasonix_server.go"}, "reasonix-server-go"},
{"go", []string{"run", "."}, "mcp-server"},
{"go", []string{"run"}, "mcp-server"},
{"go", []string{"run", "-tags"}, "mcp-server"},
{"go", []string{"-C", "workspace"}, "mcp-server"},
{"go", []string{"test", "./cmd/reasonix-server"}, "mcp-server"},
{"go", nil, "mcp-server"},
{"demo-server", []string{"run", "another-package"}, "demo-server"},
} {
t.Run(tc.command+" "+strings.Join(tc.args, " "), func(t *testing.T) {
if got := NameFromArgv(tc.command, tc.args); got != tc.name {
t.Errorf("name = %q, want %q", got, tc.name)
}
entry, err := ParseArgs(append([]string{"--", tc.command}, tc.args...))
if err != nil || entry.Name != tc.name || entry.Command != tc.command || !reflect.DeepEqual(entry.Args, tc.args) {
t.Errorf("CLI entry = %+v, err=%v", entry, err)
}
explicit, err := ParseArgs(append([]string{"manual", "--", tc.command}, tc.args...))
if err != nil || explicit.Name != "manual" || !reflect.DeepEqual(explicit.Args, tc.args) {
t.Errorf("explicit entry = %+v, err=%v", explicit, err)
}
draft, err := Parse(tc.command + " " + strings.Join(tc.args, " "))
if err != nil || len(draft.Entries) != 1 || draft.Entries[0].Name != tc.name || !reflect.DeepEqual(draft.Entries[0].Args, tc.args) {
t.Errorf("pasted draft = %+v, err=%v", draft, err)
}
})
}
}
func TestGoServerNameSkipsBuildFlagValues(t *testing.T) {
for _, flag := range []string{
"-C", "-p", "-covermode", "-coverpkg", "-asmflags", "-buildmode", "-compiler",
"-gccgoflags", "-gcflags", "-installsuffix", "-ldflags", "-mod", "-modfile",
"-overlay", "-pgo", "-pkgdir", "-tags", "-toolexec", "-exec",
} {
for _, args := range [][]string{
{"run", flag, "build-value", "example.com/tools/[email protected]", "client-mode"},
{"run", flag + "=build-value", "example.com/tools/[email protected]", "client-mode"},
{"run", flag, "build-value"},
} {
want := "reasonix-server"
if len(args) == 3 && args[2] == "build-value" {
want = "mcp-server"
}
if got := NameFromArgv("go", args); got != want {
t.Errorf("name for %v = %q, want %q", args, got, want)
}
}
}
}
func TestGoServerNamePreservesQuotedBuildArguments(t *testing.T) {
input := `go run -ldflags "-X main.mode=fixture" example.com/tools/[email protected] --stdio`
want := []string{"run", "-ldflags", "-X main.mode=fixture", "example.com/tools/[email protected]", "--stdio"}
draft, err := Parse(input)
if err != nil || len(draft.Entries) != 1 {
t.Fatalf("pasted draft = %+v, err=%v", draft, err)
}
entry := draft.Entries[0]
if entry.Name != "reasonix-server" || entry.Command != "go" || !reflect.DeepEqual(entry.Args, want) {
t.Errorf("quoted entry = %+v", entry)
}
if len(draft.Risks) != 1 || draft.Risks[0].Server != entry.Name || draft.Risks[0].Kind != "shell" || draft.Risks[0].Detail != "go "+strings.Join(want, " ") {
t.Errorf("quoted disclosure = %+v", draft.Risks)
}
}
+198
View File
@@ -0,0 +1,198 @@
package mcpsetup
import (
"slices"
"strings"
)
type launcherFlag uint8
const (
launcherBoolean launcherFlag = iota
launcherValue
launcherShell
launcherModule
)
type commandLauncher struct {
commands map[string][]string
flags map[string]launcherFlag
image bool
}
var containerLauncher = commandLauncher{
commands: map[string][]string{"run": nil, "container": {"run"}},
image: true,
flags: map[string]launcherFlag{
"rm": launcherBoolean, "i": launcherBoolean, "interactive": launcherBoolean,
"t": launcherBoolean, "tty": launcherBoolean, "d": launcherBoolean, "detach": launcherBoolean,
"q": launcherBoolean, "quiet": launcherBoolean, "P": launcherBoolean, "publish-all": launcherBoolean,
"init": launcherBoolean, "read-only": launcherBoolean, "sig-proxy": launcherBoolean,
"no-healthcheck": launcherBoolean,
"e": launcherValue, "env": launcherValue, "env-file": launcherValue,
"v": launcherValue, "volume": launcherValue, "mount": launcherValue,
"u": launcherValue, "user": launcherValue, "w": launcherValue, "workdir": launcherValue,
"p": launcherValue, "publish": launcherValue, "name": launcherValue,
"network": launcherValue, "platform": launcherValue, "entrypoint": launcherValue, "pull": launcherValue,
"c": launcherValue, "context": launcherValue, "H": launcherValue, "host": launcherValue,
"n": launcherValue, "namespace": launcherValue, "root": launcherValue,
},
}
var packageLauncher = commandLauncher{
flags: map[string]launcherFlag{
"p": launcherValue, "package": launcherValue, "node-options": launcherValue,
"y": launcherBoolean, "yes": launcherBoolean, "no": launcherBoolean,
"quiet": launcherBoolean, "q": launcherBoolean, "color": launcherBoolean,
"no-install": launcherBoolean, "ignore-existing": launcherBoolean, "bun": launcherBoolean,
"c": launcherShell, "call": launcherShell,
},
}
var pythonLauncher = commandLauncher{
flags: map[string]launcherFlag{
"m": launcherModule, "c": launcherShell,
"W": launcherValue, "X": launcherValue, "check-hash-based-pycs": launcherValue,
"b": launcherBoolean, "B": launcherBoolean, "d": launcherBoolean, "E": launcherBoolean,
"i": launcherBoolean, "I": launcherBoolean, "O": launcherBoolean, "P": launcherBoolean,
"q": launcherBoolean, "s": launcherBoolean, "S": launcherBoolean, "u": launcherBoolean,
"v": launcherBoolean, "x": launcherBoolean,
},
}
var nodeLauncher = commandLauncher{
flags: map[string]launcherFlag{
"r": launcherValue, "require": launcherValue, "import": launcherValue,
"env-file": launcherValue, "env-file-if-exists": launcherValue,
"conditions": launcherValue, "C": launcherValue,
"e": launcherShell, "eval": launcherShell, "p": launcherShell, "print": launcherShell,
"inspect": launcherBoolean, "inspect-brk": launcherBoolean, "trace-warnings": launcherBoolean,
},
}
var uvLauncher = commandLauncher{
flags: map[string]launcherFlag{
"directory": launcherValue, "project": launcherValue, "config-file": launcherValue,
"cache-dir": launcherValue, "color": launcherValue, "allow-insecure-host": launcherValue,
"p": launcherValue, "python": launcherValue, "from": launcherValue,
"w": launcherValue, "with": launcherValue, "with-requirements": launcherValue,
"offline": launcherBoolean, "no-config": launcherBoolean, "no-cache": launcherBoolean,
"q": launcherBoolean, "quiet": launcherBoolean, "v": launcherBoolean, "verbose": launcherBoolean,
"no-progress": launcherBoolean, "locked": launcherBoolean, "frozen": launcherBoolean,
"no-sync": launcherBoolean,
},
}
var commandLaunchers = map[string]commandLauncher{
"docker": containerLauncher, "podman": containerLauncher, "nerdctl": containerLauncher,
"npx": packageLauncher, "bunx": packageLauncher,
"python": pythonLauncher, "python3": pythonLauncher, "py": pythonLauncher,
"node": nodeLauncher, "uvx": uvLauncher,
"uv": {commands: map[string][]string{"run": nil}, flags: uvLauncher.flags},
"pnpm": {
commands: map[string][]string{"dlx": nil},
flags: map[string]launcherFlag{
"package": launcherValue, "allow-build": launcherValue, "reporter": launcherValue,
"dir": launcherValue, "C": launcherValue, "filter": launcherValue,
"silent": launcherBoolean, "s": launcherBoolean, "shell-mode": launcherBoolean,
"c": launcherBoolean, "global": launcherBoolean, "g": launcherBoolean,
},
},
"npm": {
commands: map[string][]string{"exec": nil, "x": nil},
flags: map[string]launcherFlag{
"package": launcherValue, "workspace": launcherValue, "w": launcherValue,
"prefix": launcherValue, "cache": launcherValue, "userconfig": launcherValue, "registry": launcherValue,
"yes": launcherBoolean, "y": launcherBoolean, "no": launcherBoolean,
"p": launcherBoolean, "parseable": launcherBoolean,
"call": launcherShell, "c": launcherShell,
},
},
"go": {
commands: map[string][]string{"run": nil},
flags: map[string]launcherFlag{
"C": launcherValue, "p": launcherValue, "covermode": launcherValue, "coverpkg": launcherValue,
"asmflags": launcherValue, "buildmode": launcherValue, "compiler": launcherValue,
"gccgoflags": launcherValue, "gcflags": launcherValue, "installsuffix": launcherValue,
"ldflags": launcherValue, "mod": launcherValue, "modfile": launcherValue,
"overlay": launcherValue, "pgo": launcherValue, "pkgdir": launcherValue,
"tags": launcherValue, "toolexec": launcherValue, "exec": launcherValue,
"a": launcherBoolean, "n": launcherBoolean, "race": launcherBoolean,
"msan": launcherBoolean, "asan": launcherBoolean, "cover": launcherBoolean,
"v": launcherBoolean, "work": launcherBoolean, "x": launcherBoolean,
"buildvcs": launcherBoolean, "json": launcherBoolean, "linkshared": launcherBoolean,
"modcacherw": launcherBoolean, "trimpath": launcherBoolean,
},
},
}
func launcherCommandOperand(args []string, launcher commandLauncher) string {
options, needsCommand := true, len(launcher.commands) != 0
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "" {
continue
}
if options && arg == "--" {
options = false
continue
}
if options && strings.HasPrefix(arg, "-") {
kind, value, width, ok := launcherOption(arg, launcher)
if !ok || i+width >= len(args) {
return ""
}
if width != 0 {
value = args[i+width]
}
if kind == launcherModule {
return value
}
i += width
continue
}
if needsCommand {
suffix, ok := launcher.commands[arg]
if !ok || i+len(suffix) >= len(args) || !slices.Equal(args[i+1:i+1+len(suffix)], suffix) {
return ""
}
i += len(suffix)
needsCommand = false
continue
}
return arg
}
return ""
}
func launcherOption(arg string, launcher commandLauncher) (launcherFlag, string, int, bool) {
key, value, inline := strings.Cut(strings.TrimLeft(arg, "-"), "=")
if key == "" {
return 0, "", 0, false
}
if kind, ok := launcher.flags[key]; ok {
if kind == launcherShell {
return 0, "", 0, false
}
if kind != launcherBoolean && !inline {
return kind, "", 1, true
}
return kind, value, 0, true
}
if strings.HasPrefix(arg, "--") {
return launcherBoolean, "", 0, len(launcher.commands) == 0
}
for i := 1; i < len(arg); i++ {
kind, ok := launcher.flags[arg[i:i+1]]
if !ok && len(launcher.commands) != 0 || kind == launcherShell {
return 0, "", 0, false
}
if kind != launcherBoolean {
if i+1 == len(arg) {
return kind, "", 1, true
}
return kind, strings.TrimPrefix(arg[i+1:], "="), 0, true
}
}
return launcherBoolean, "", 0, true
}
@@ -0,0 +1,67 @@
package mcpsetup
import (
"reflect"
"testing"
)
func TestLauncherNamesAcrossGlobalOptionsAndUnknownFlags(t *testing.T) {
for _, tc := range []struct {
line string
name string
}{
{"docker run -p 8080:80 img", "img"},
{"docker run localhost:5000/img:tag", "img"},
{"docker run --cap-add SYS_ADMIN img", "mcp-server"},
{"docker run img@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "img"},
{"node --experimental-vm-modules server.js", "server"},
{"node --max-old-space-size=4096 server.js", "server"},
{"npx --unknown option-value pkg", "option-value"},
{"npx -Z pkg", "pkg"},
{"bunx --unknown=option-value pkg", "pkg"},
{"python3 --unknown option-value server.py", "option-value"},
{"python3 --unknown -m server_module", "server-module"},
{"node --unknown option-value server.js", "option-value"},
{"uv --unknown option-value run pkg", "mcp-server"},
{"uvx --unknown option-value pkg", "option-value"},
{"npx -c 'pkg --stdio' client-mode", "mcp-server"},
{"python3 -Imreasonix_probe", "reasonix-probe"},
{"docker run -p8080:80 --publish=9000:90 registry.example:5000/tools/server:stable", "server"},
{"docker --context work run --rm -it mcp/time", "time"},
{"docker -H unix:///tmp/fixture.sock container run --rm mcp/time", "time"},
{"podman run --rm -i -p 8080:80 mcp/time", "time"},
{"nerdctl --namespace work run --rm -i mcp/fetch:latest", "fetch"},
{"pnpm --dir x dlx pkg", "pkg"},
{"pnpm -C x --reporter append-only dlx --package dep pkg@latest", "pkg"},
{"npm --prefix x --cache cache exec --package dep -- pkg@latest", "pkg"},
{`"C:\Program Files\nodejs\pnpm.CMD" --dir x dlx pkg`, "pkg"},
{`C:\nodejs\npm.CMD exec -- pkg`, "pkg"},
{`C:\Go\bin\go.EXE run .\cmd\server --stdio`, "server"},
{`C:\Docker\docker.EXE run --rm -i mcp/time`, "time"},
{"go run .", "mcp-server"},
{"go run -unknown option-value ./cmd/server", "mcp-server"},
{"pnpm --unknown option-value dlx pkg", "mcp-server"},
{"npm exec --unknown option-value pkg", "mcp-server"},
{"docker run --unknown=option-value img", "mcp-server"},
{"podman run --unknown option-value img", "mcp-server"},
{"nerdctl run --unknown option-value img", "mcp-server"},
{"docker run -p", "mcp-server"},
{"pnpm --dir x dlx", "mcp-server"},
{"npm exec --call 'pkg --stdio'", "mcp-server"},
{"go run - ./cmd/server", "mcp-server"},
{"docker run - mcp/time", "mcp-server"},
{"docker run -- img --unknown server-value", "img"},
} {
t.Run(tc.line, func(t *testing.T) {
argv := Tokenize(tc.line)
entry, err := ParseArgs(append([]string{"--"}, argv...))
if err != nil || entry.Name != tc.name || entry.Command != argv[0] || !reflect.DeepEqual(entry.Args, argv[1:]) {
t.Errorf("CLI entry = %+v, err=%v, want %q with original argv", entry, err, tc.name)
}
draft, err := Parse(tc.line)
if err != nil || len(draft.Entries) != 1 || !reflect.DeepEqual(draft.Entries[0], entry) {
t.Errorf("draft = %+v, err=%v", draft, err)
}
})
}
}
+60
View File
@@ -0,0 +1,60 @@
package mcpsetup
import (
"reflect"
"testing"
)
func TestNPMExecNameUsesExecutableOperand(t *testing.T) {
for _, tc := range []struct {
line string
name string
}{
{"npm exec -- @example/[email protected] --port 3456", "docs-mcp"},
{"npm exec --yes @example/search-mcp@latest", "search-mcp"},
{"npm --yes exec -- docs-mcp", "docs-mcp"},
{"npm x -- docs-mcp", "docs-mcp"},
{"npm exec --package @example/client -- docs-mcp", "docs-mcp"},
{"npm --package @example/client exec -- docs-mcp", "docs-mcp"},
{"npm exec --package=@example/client -- docs-mcp", "docs-mcp"},
{"npm exec --package first --package second docs-mcp", "docs-mcp"},
{"npm exec --workspace packages/client -- docs-mcp", "docs-mcp"},
{"npm exec -w packages/client -- docs-mcp", "docs-mcp"},
{"npm exec --workspace=packages/client -- docs-mcp", "docs-mcp"},
{"npm exec -p -- docs-mcp", "docs-mcp"},
{"npm exec -- docs-mcp --package server-value", "docs-mcp"},
{"npm install docs-mcp", "mcp-server"},
{"npm run docs-mcp", "mcp-server"},
{"npm exec --package @example/client", "mcp-server"},
{"npm exec --call 'docs-mcp --stdio'", "mcp-server"},
{"npm exec -c 'docs-mcp --stdio'", "mcp-server"},
{"npm exec --call=docs-mcp", "mcp-server"},
} {
t.Run(tc.line, func(t *testing.T) {
tokens := Tokenize(tc.line)
entry, err := ParseArgs(append([]string{"--"}, tokens...))
if err != nil {
t.Fatal(err)
}
if entry.Name != tc.name {
t.Errorf("name = %q, want %q", entry.Name, tc.name)
}
if entry.Command != tokens[0] || !reflect.DeepEqual(entry.Args, tokens[1:]) {
t.Errorf("argv changed: %+v", entry)
}
draft, err := Parse(tc.line)
if err != nil || len(draft.Entries) != 1 || !reflect.DeepEqual(draft.Entries[0], entry) {
t.Errorf("pasted preview = %+v, err=%v", draft, err)
}
})
}
for _, command := range []string{"/usr/bin/npm", "npm.CMD", "npm.EXE", "npm.BAT"} {
if got := NameFromArgv(command, []string{"exec", "--", "docs-mcp"}); got != "docs-mcp" {
t.Errorf("%s derived %q", command, got)
}
}
entry, err := ParseArgs([]string{"custom", "--", "npm", "exec", "--", "docs-mcp"})
if err != nil || entry.Name != "custom" {
t.Errorf("explicit name = %+v, err=%v", entry, err)
}
}
+54
View File
@@ -0,0 +1,54 @@
package mcpsetup
import (
"reflect"
"strings"
"testing"
)
func TestPNPMDlxServerNameUsesCommandOperand(t *testing.T) {
for _, tc := range []struct {
args []string
name string
}{
{[]string{"dlx", "@example/[email protected]", "--port", "3456"}, "docs-mcp"},
{[]string{"dlx", "search-mcp@latest"}, "search-mcp"},
{[]string{"--package", "@example/client", "dlx", "docs-mcp"}, "docs-mcp"},
{[]string{"--package=@example/client", "dlx", "docs-mcp"}, "docs-mcp"},
{[]string{"dlx", "--package", "@example/client", "docs-mcp"}, "docs-mcp"},
{[]string{"--package", "dlx", "--package", "@example/client", "dlx", "docs-mcp"}, "docs-mcp"},
{[]string{"--reporter", "append-only", "dlx", "--allow-build", "@example/client", "docs-mcp"}, "docs-mcp"},
{[]string{"--silent", "dlx", "--shell-mode", "docs-mcp"}, "docs-mcp"},
{[]string{"dlx", "-c", "docs-mcp"}, "docs-mcp"},
{[]string{"dlx", "--", "docs-mcp", "--package", "literal-argument"}, "docs-mcp"},
{[]string{"exec", "docs-mcp"}, "mcp-server"},
{[]string{"dlx"}, "mcp-server"},
{[]string{"--package"}, "mcp-server"},
} {
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
if got := NameFromArgv("pnpm", tc.args); got != tc.name {
t.Errorf("name = %q, want %q", got, tc.name)
}
entry, err := ParseArgs(append([]string{"--", "pnpm"}, tc.args...))
if err != nil || entry.Name != tc.name || entry.Command != "pnpm" || !reflect.DeepEqual(entry.Args, tc.args) {
t.Errorf("CLI entry = %+v, err=%v", entry, err)
}
draft, err := Parse("pnpm " + strings.Join(tc.args, " "))
if err != nil || len(draft.Entries) != 1 || !reflect.DeepEqual(draft.Entries[0], entry) {
t.Errorf("pasted draft = %+v, err=%v; want %+v", draft, err, entry)
}
explicit, err := ParseArgs(append([]string{"manual", "--", "pnpm"}, tc.args...))
if err != nil || explicit.Name != "manual" || !reflect.DeepEqual(explicit.Args, tc.args) {
t.Errorf("explicit entry = %+v, err=%v", explicit, err)
}
})
}
for _, command := range []string{"/usr/local/bin/pnpm", "pnpm.CMD", "pnpm.EXE"} {
if got := NameFromArgv(command, []string{"dlx", "@example/[email protected]"}); got != "docs-mcp" {
t.Errorf("runner %q name = %q", command, got)
}
}
if got := NameFromArgv("custom", []string{"dlx", "docs-mcp"}); got != "custom" {
t.Errorf("custom command control = %q", got)
}
}
-38
View File
@@ -1,38 +0,0 @@
package mcpsetup
import "strings"
func uvCommandOperand(args []string, needsRun bool) string {
valueFlags := map[string]bool{
"--directory": true, "--project": true, "--config-file": true,
"--cache-dir": true, "--color": true, "--allow-insecure-host": true,
"-p": true, "--python": true, "--from": true,
"-w": true, "--with": true, "--with-requirements": true,
}
options := true
for i := 0; i < len(args); i++ {
arg := strings.TrimSpace(args[i])
if options && arg == "--" {
options = false
continue
}
if options && strings.HasPrefix(arg, "-") {
if valueFlags[arg] {
i++
}
continue
}
if arg == "" {
continue
}
if needsRun {
if arg != "run" {
return ""
}
needsRun = false
continue
}
return arg
}
return ""
}
@@ -0,0 +1,60 @@
package cli
import (
"reflect"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/ext/plugin"
)
func TestMCPAddFallbackNameRefusesCollisionWithoutOverwriting(t *testing.T) {
home := testenv.TempDir(t)
t.Setenv("REASONIX_HOME", home)
t.Setenv("REASONIX_STATE_HOME", home)
workspace := testenv.TempDir(t)
t.Chdir(workspace)
var probed []config.PluginEntry
previous := mcpProbeForInstall
mcpProbeForInstall = func(entry config.PluginEntry) (plugin.MCPInstallResult, error) {
probed = append(probed, entry)
return plugin.ReadyInstallResult(entry.Name, 1), nil
}
t.Cleanup(func() { mcpProbeForInstall = previous })
first := []string{"run", "--unknown", "first-option", "first-image"}
second := []string{"run", "--unknown=second-option", "second-image"}
if code := mcpAddCLI(append([]string{"--", "docker"}, first...)); code != 0 {
t.Fatalf("first install exited %d", code)
}
before, err := config.LoadForRoot(workspace)
if err != nil || len(before.Plugins) != 1 || len(probed) != 1 {
t.Fatalf("first config=%+v probes=%v err=%v", before, probed, err)
}
if entry := before.Plugins[0]; entry.Name != "mcp-server" || entry.Command != "docker" || !reflect.DeepEqual(entry.Args, first) {
t.Fatalf("first entry = %+v", entry)
}
if code := mcpAddCLI(append([]string{"--", "docker"}, second...)); code == 0 || len(probed) != 1 {
t.Fatalf("duplicate exit=%d probes=%d; want rejection before probing", code, len(probed))
}
after, err := config.LoadForRoot(workspace)
if err != nil || !reflect.DeepEqual(after.Plugins, before.Plugins) {
t.Fatalf("duplicate replaced config: before=%+v after=%+v err=%v", before, after, err)
}
if code := mcpAddCLI(append([]string{"second-server", "--", "docker"}, second...)); code != 0 {
t.Fatalf("explicit-name install exited %d", code)
}
after, err = config.LoadForRoot(workspace)
if err != nil || len(after.Plugins) != 2 || len(probed) != 2 {
t.Fatalf("explicit-name config=%+v probes=%v err=%v", after, probed, err)
}
if entry := probed[1]; entry.Name != "second-server" || entry.Command != "docker" || !reflect.DeepEqual(entry.Args, second) {
t.Errorf("explicit-name probe = %+v", entry)
}
if entry := after.Plugins[0]; !reflect.DeepEqual(entry, before.Plugins[0]) {
t.Errorf("explicit-name install changed first entry: %+v", entry)
}
if entry := after.Plugins[1]; entry.Name != "second-server" || entry.Command != "docker" || !reflect.DeepEqual(entry.Args, second) {
t.Errorf("explicit-name entry = %+v", entry)
}
}
+63
View File
@@ -0,0 +1,63 @@
package cli
import (
"reflect"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/ext/plugin"
)
func TestMCPAddNPMExecPersistsDistinctServerNames(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
t.Setenv("REASONIX_STATE_HOME", testenv.TempDir(t))
workspace := testenv.TempDir(t)
t.Chdir(workspace)
var probed []config.PluginEntry
previous := mcpProbeForInstall
mcpProbeForInstall = func(entry config.PluginEntry) (plugin.MCPInstallResult, error) {
probed = append(probed, entry)
return plugin.ReadyInstallResult(entry.Name, 1), nil
}
t.Cleanup(func() { mcpProbeForInstall = previous })
entries := []struct {
name string
args []string
}{
{"docs-mcp", []string{"exec", "--", "@example/[email protected]", "--port", "3456"}},
{"search-mcp", []string{"exec", "--package", "@example/search-client", "--", "search-mcp"}},
}
for _, entry := range entries {
if code := mcpAddCLI(append([]string{"--", "npm"}, entry.args...)); code != 0 {
t.Fatalf("adding %s exited %d", entry.name, code)
}
}
cfg, err := config.LoadForRoot(workspace)
if err != nil {
t.Fatal(err)
}
if len(cfg.Plugins) != len(entries) || len(probed) != len(entries) {
t.Fatalf("persisted %d entries, probed %d; want two distinct entries", len(cfg.Plugins), len(probed))
}
for i, want := range entries {
if probe := probed[i]; probe.Name != want.name || probe.Command != "npm" || !reflect.DeepEqual(probe.Args, want.args) {
t.Errorf("probe = %+v, want %s with unchanged argv", probe, want.name)
}
found := false
for _, got := range cfg.Plugins {
if got.Name == want.name {
found = true
if got.Command != "npm" || !reflect.DeepEqual(got.Args, want.args) || got.Source != config.MCPSourceUserConfig {
t.Errorf("persisted entry = %+v", got)
}
}
}
if !found {
t.Errorf("missing persisted %s", want.name)
}
}
if code := mcpAddCLI(append([]string{"--", "npm"}, entries[0].args...)); code == 0 || len(probed) != 2 {
t.Errorf("duplicate exit=%d, probes=%d; want rejection before probing", code, len(probed))
}
}
+63
View File
@@ -0,0 +1,63 @@
package cli
import (
"reflect"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/ext/plugin"
)
func TestMCPAddPNPMDlxPersistsDistinctServerNames(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
t.Setenv("REASONIX_STATE_HOME", testenv.TempDir(t))
workspace := testenv.TempDir(t)
t.Chdir(workspace)
var probed []config.PluginEntry
previous := mcpProbeForInstall
mcpProbeForInstall = func(entry config.PluginEntry) (plugin.MCPInstallResult, error) {
probed = append(probed, entry)
return plugin.ReadyInstallResult(entry.Name, 1), nil
}
t.Cleanup(func() { mcpProbeForInstall = previous })
entries := []struct {
name string
args []string
}{
{"docs-mcp", []string{"dlx", "@example/[email protected]", "--port", "3456"}},
{"search-mcp", []string{"--package", "@example/search-client", "dlx", "search-mcp"}},
}
for _, entry := range entries {
if code := mcpAddCLI(append([]string{"--", "pnpm"}, entry.args...)); code != 0 {
t.Fatalf("adding %s exited %d", entry.name, code)
}
}
cfg, err := config.LoadForRoot(workspace)
if err != nil {
t.Fatal(err)
}
if len(cfg.Plugins) != len(entries) || len(probed) != len(entries) {
t.Fatalf("persisted %d entries, probed %d; want two distinct entries", len(cfg.Plugins), len(probed))
}
for i, want := range entries {
if probe := probed[i]; probe.Name != want.name || probe.Command != "pnpm" || !reflect.DeepEqual(probe.Args, want.args) {
t.Errorf("probe = %+v, want %s with unchanged argv", probe, want.name)
}
found := false
for _, got := range cfg.Plugins {
if got.Name == want.name {
found = true
if got.Command != "pnpm" || !reflect.DeepEqual(got.Args, want.args) || got.Source != config.MCPSourceUserConfig {
t.Errorf("persisted entry = %+v", got)
}
}
}
if !found {
t.Errorf("missing persisted %s", want.name)
}
}
if code := mcpAddCLI(append([]string{"--", "pnpm"}, entries[0].args...)); code == 0 || len(probed) != 2 {
t.Errorf("duplicate exit=%d, probes=%d; want rejection before probing", code, len(probed))
}
}
@@ -0,0 +1,52 @@
package serve
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"reasonix/internal/contract/config"
"reasonix/internal/session/control"
)
func TestMCPParseDockerNameUsesImageOperand(t *testing.T) {
ctrl := control.New(control.Options{})
t.Cleanup(ctrl.Close)
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
t.Cleanup(srv.Close)
for _, tc := range []struct {
input string
name string
}{
{"docker run --rm -i mcp/time", "time"},
{"docker run --rm -i mcp/fetch:latest", "fetch"},
{"docker run -e MODE=test -v /data:/data mcp/filesystem /data", "filesystem"},
{"docker run --network none registry.example:5000/tools/time:stable", "time"},
{"docker container run --rm -i mcp/time@sha256:" + strings.Repeat("0123456789abcdef", 4), "time"},
{"docker run --unknown option-value mcp/time", "mcp-server"},
{"docker version", "mcp-server"},
} {
resp := postJSON(t, srv.URL+"/mcp/parse", map[string]string{"input": tc.input})
var got struct {
Servers []draftServer `json:"servers"`
Risks []draftRisk `json:"risks"`
}
err := json.NewDecoder(resp.Body).Decode(&got)
resp.Body.Close()
if err != nil || resp.StatusCode != http.StatusOK || len(got.Servers) != 1 {
t.Fatalf("preview %q: status=%d body=%+v err=%v", tc.input, resp.StatusCode, got, err)
}
entry := got.Servers[0]
if entry.Name != tc.name || entry.Transport != "stdio" || entry.Command+" "+strings.Join(entry.Args, " ") != tc.input {
t.Errorf("preview %q = %+v, want name %q and original argv", tc.input, entry, tc.name)
}
if len(got.Risks) != 1 || got.Risks[0].Server != tc.name || got.Risks[0].Kind != "shell" || got.Risks[0].Detail != tc.input {
t.Errorf("preview disclosure = %+v", got.Risks)
}
}
if names := ctrl.ConfiguredMCPNames(); len(names) != 0 {
t.Errorf("preview installed servers: %v", names)
}
}
@@ -0,0 +1,54 @@
package serve
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"reasonix/internal/contract/config"
"reasonix/internal/session/control"
)
func TestMCPParseGoNameUsesRunPackage(t *testing.T) {
ctrl := control.New(control.Options{})
t.Cleanup(ctrl.Close)
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
t.Cleanup(srv.Close)
for _, tc := range []struct {
input string
name string
}{
{"go run example.com/tools/reasonix-server@latest --stdio", "reasonix-server"},
{"go run -tags=integration example.com/tools/[email protected] --stdio", "reasonix-server"},
{"go run -tags integration example.com/tools/[email protected] --stdio", "reasonix-server"},
{"go -C workspace run ./cmd/reasonix-server client-mode", "reasonix-server"},
{"/usr/local/go/bin/go run ./cmd/reasonix-server client-mode", "reasonix-server"},
{"go run -tags integration", "mcp-server"},
{"go test ./cmd/reasonix-server", "mcp-server"},
{"reasonix mcp add manual -- go run ./cmd/reasonix-server", "manual"},
} {
resp := postJSON(t, srv.URL+"/mcp/parse", map[string]string{"input": tc.input})
var got struct {
Servers []draftServer `json:"servers"`
Risks []draftRisk `json:"risks"`
}
err := json.NewDecoder(resp.Body).Decode(&got)
resp.Body.Close()
if err != nil || resp.StatusCode != http.StatusOK || len(got.Servers) != 1 {
t.Fatalf("preview %q: status=%d body=%+v err=%v", tc.input, resp.StatusCode, got, err)
}
entry := got.Servers[0]
command := strings.TrimPrefix(tc.input, "reasonix mcp add manual -- ")
if entry.Name != tc.name || entry.Transport != "stdio" || entry.Command+" "+strings.Join(entry.Args, " ") != command {
t.Errorf("preview %q = %+v, want name %q and original argv", tc.input, entry, tc.name)
}
if len(got.Risks) != 1 || got.Risks[0].Server != tc.name || got.Risks[0].Kind != "shell" || got.Risks[0].Detail != command {
t.Errorf("preview disclosure = %+v", got.Risks)
}
}
if names := ctrl.ConfiguredMCPNames(); len(names) != 0 {
t.Errorf("preview installed servers: %v", names)
}
}
@@ -0,0 +1,67 @@
package serve
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"reasonix/internal/contract/config"
"reasonix/internal/ext/mcpsetup"
"reasonix/internal/session/control"
)
func TestMCPParseLauncherOptionsKeepProposedNames(t *testing.T) {
ctrl := control.New(control.Options{})
t.Cleanup(ctrl.Close)
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
t.Cleanup(srv.Close)
for _, tc := range []struct {
input string
name string
}{
{"docker run -p 8080:80 img", "img"},
{"docker run localhost:5000/img:tag", "img"},
{"docker run --cap-add SYS_ADMIN img", "mcp-server"},
{"docker run img@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "img"},
{"node --experimental-vm-modules server.js", "server"},
{"node --max-old-space-size=4096 server.js", "server"},
{"npx --unknown option-value pkg", "option-value"},
{"python3 --unknown option-value server.py", "option-value"},
{"node --unknown option-value server.js", "option-value"},
{"uv --unknown option-value run pkg", "mcp-server"},
{"uvx --unknown option-value pkg", "option-value"},
{"pnpm --dir x dlx pkg", "pkg"},
{"npm --prefix x exec -- pkg", "pkg"},
{"podman run --rm -i mcp/time", "time"},
{"nerdctl --namespace x run --rm -i mcp/fetch:latest", "fetch"},
{`"C:\Program Files\nodejs\pnpm.CMD" --dir x dlx pkg`, "pkg"},
{`C:\Go\bin\go.EXE run .\cmd\server`, "server"},
{"go run .", "mcp-server"},
{"docker run --unknown option-value img", "mcp-server"},
{"pnpm --unknown option-value dlx pkg", "mcp-server"},
} {
resp := postJSON(t, srv.URL+"/mcp/parse", map[string]string{"input": tc.input})
var got struct {
Servers []draftServer `json:"servers"`
Risks []draftRisk `json:"risks"`
}
err := json.NewDecoder(resp.Body).Decode(&got)
resp.Body.Close()
if err != nil || resp.StatusCode != http.StatusOK || len(got.Servers) != 1 {
t.Fatalf("preview %q: status=%d body=%+v err=%v", tc.input, resp.StatusCode, got, err)
}
argv := mcpsetup.Tokenize(tc.input)
entry := got.Servers[0]
if entry.Name != tc.name || entry.Transport != "stdio" || entry.Command != argv[0] || strings.Join(entry.Args, " ") != strings.Join(argv[1:], " ") {
t.Errorf("preview %q = %+v, want name %q and original argv", tc.input, entry, tc.name)
}
if len(got.Risks) != 1 || got.Risks[0].Server != tc.name || got.Risks[0].Kind != "shell" || got.Risks[0].Detail != strings.Join(argv, " ") {
t.Errorf("preview disclosure = %+v", got.Risks)
}
}
if names := ctrl.ConfiguredMCPNames(); len(names) != 0 {
t.Errorf("preview installed servers: %v", names)
}
}
@@ -0,0 +1,50 @@
package serve
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"reasonix/internal/contract/config"
"reasonix/internal/session/control"
)
func TestMCPParseNPMExecKeepsDistinctServerNames(t *testing.T) {
ctrl := control.New(control.Options{})
t.Cleanup(ctrl.Close)
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
t.Cleanup(srv.Close)
for _, tc := range []struct {
input string
name string
}{
{"npm exec -- @example/[email protected] --port 3456", "docs-mcp"},
{"npm exec --yes @example/search-mcp@latest", "search-mcp"},
{"npm exec --package @example/client -- docs-mcp", "docs-mcp"},
{"npm x --package=@example/client -- docs-mcp", "docs-mcp"},
{"npm exec -w packages/client -- docs-mcp", "docs-mcp"},
} {
resp := postJSON(t, srv.URL+"/mcp/parse", map[string]string{"input": tc.input})
var got struct {
Servers []draftServer `json:"servers"`
Risks []draftRisk `json:"risks"`
}
err := json.NewDecoder(resp.Body).Decode(&got)
resp.Body.Close()
if err != nil || resp.StatusCode != http.StatusOK || len(got.Servers) != 1 {
t.Fatalf("preview %q: status=%d body=%+v err=%v", tc.input, resp.StatusCode, got, err)
}
e := got.Servers[0]
if e.Name != tc.name || e.Transport != "stdio" || e.Command+" "+strings.Join(e.Args, " ") != tc.input {
t.Errorf("preview %q = %+v, want name %q and original argv", tc.input, e, tc.name)
}
if len(got.Risks) != 1 || got.Risks[0].Server != tc.name || got.Risks[0].Kind != "shell" || got.Risks[0].Detail != tc.input {
t.Errorf("preview disclosure = %+v", got.Risks)
}
}
if names := ctrl.ConfiguredMCPNames(); len(names) != 0 {
t.Errorf("preview installed servers: %v", names)
}
}
@@ -0,0 +1,50 @@
package serve
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"reasonix/internal/contract/config"
"reasonix/internal/session/control"
)
func TestMCPParsePNPMDlxKeepsDistinctServerNames(t *testing.T) {
ctrl := control.New(control.Options{})
t.Cleanup(ctrl.Close)
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
t.Cleanup(srv.Close)
for _, tc := range []struct {
input string
name string
}{
{"pnpm dlx @example/[email protected] --port 3456", "docs-mcp"},
{"pnpm dlx search-mcp@latest", "search-mcp"},
{"pnpm --package @example/client dlx docs-mcp", "docs-mcp"},
{"pnpm --reporter append-only dlx --allow-build @example/client docs-mcp", "docs-mcp"},
{"pnpm dlx -c docs-mcp", "docs-mcp"},
} {
resp := postJSON(t, srv.URL+"/mcp/parse", map[string]string{"input": tc.input})
var got struct {
Servers []draftServer `json:"servers"`
Risks []draftRisk `json:"risks"`
}
err := json.NewDecoder(resp.Body).Decode(&got)
resp.Body.Close()
if err != nil || resp.StatusCode != http.StatusOK || len(got.Servers) != 1 {
t.Fatalf("preview %q: status=%d body=%+v err=%v", tc.input, resp.StatusCode, got, err)
}
e := got.Servers[0]
if e.Name != tc.name || e.Transport != "stdio" || e.Command+" "+strings.Join(e.Args, " ") != tc.input {
t.Errorf("preview %q = %+v, want name %q and original argv", tc.input, e, tc.name)
}
if len(got.Risks) != 1 || got.Risks[0].Server != tc.name || got.Risks[0].Kind != "shell" || got.Risks[0].Detail != tc.input {
t.Errorf("preview disclosure = %+v", got.Risks)
}
}
if names := ctrl.ConfiguredMCPNames(); len(names) != 0 {
t.Errorf("preview installed servers: %v", names)
}
}