diff --git a/internal/ext/mcpsetup/argv.go b/internal/ext/mcpsetup/argv.go index 422fa6e6e3..a6f6a7b303 100644 --- a/internal/ext/mcpsetup/argv.go +++ b/internal/ext/mcpsetup/argv.go @@ -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 { diff --git a/internal/ext/mcpsetup/docker_operand_test.go b/internal/ext/mcpsetup/docker_operand_test.go new file mode 100644 index 0000000000..d0961c449e --- /dev/null +++ b/internal/ext/mcpsetup/docker_operand_test.go @@ -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) + } +} diff --git a/internal/ext/mcpsetup/go_operand_test.go b/internal/ext/mcpsetup/go_operand_test.go new file mode 100644 index 0000000000..b9cce650ae --- /dev/null +++ b/internal/ext/mcpsetup/go_operand_test.go @@ -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/reasonix-server@v1.2.3"}, "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/reasonix-server@v1.2.3", "client-mode"}, + {"run", flag + "=build-value", "example.com/tools/reasonix-server@v1.2.3", "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/reasonix-server@v1.2.3 --stdio` + want := []string{"run", "-ldflags", "-X main.mode=fixture", "example.com/tools/reasonix-server@v1.2.3", "--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) + } +} diff --git a/internal/ext/mcpsetup/launcher_argv.go b/internal/ext/mcpsetup/launcher_argv.go new file mode 100644 index 0000000000..975a50a547 --- /dev/null +++ b/internal/ext/mcpsetup/launcher_argv.go @@ -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 +} diff --git a/internal/ext/mcpsetup/launcher_argv_test.go b/internal/ext/mcpsetup/launcher_argv_test.go new file mode 100644 index 0000000000..dfb40482f3 --- /dev/null +++ b/internal/ext/mcpsetup/launcher_argv_test.go @@ -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) + } + }) + } +} diff --git a/internal/ext/mcpsetup/npm_exec_test.go b/internal/ext/mcpsetup/npm_exec_test.go new file mode 100644 index 0000000000..2e04c6744d --- /dev/null +++ b/internal/ext/mcpsetup/npm_exec_test.go @@ -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/docs-mcp@1.2.0 --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) + } +} diff --git a/internal/ext/mcpsetup/pnpm_dlx_test.go b/internal/ext/mcpsetup/pnpm_dlx_test.go new file mode 100644 index 0000000000..f7f2402e5d --- /dev/null +++ b/internal/ext/mcpsetup/pnpm_dlx_test.go @@ -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/docs-mcp@1.2.0", "--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/docs-mcp@1.2.0"}); 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) + } +} diff --git a/internal/ext/mcpsetup/uv_argv.go b/internal/ext/mcpsetup/uv_argv.go deleted file mode 100644 index 736dda6c91..0000000000 --- a/internal/ext/mcpsetup/uv_argv.go +++ /dev/null @@ -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 "" -} diff --git a/internal/frontend/cli/mcp_fallback_name_test.go b/internal/frontend/cli/mcp_fallback_name_test.go new file mode 100644 index 0000000000..b7474cdceb --- /dev/null +++ b/internal/frontend/cli/mcp_fallback_name_test.go @@ -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) + } +} diff --git a/internal/frontend/cli/mcp_npm_test.go b/internal/frontend/cli/mcp_npm_test.go new file mode 100644 index 0000000000..d849886ef5 --- /dev/null +++ b/internal/frontend/cli/mcp_npm_test.go @@ -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/docs-mcp@1.2.0", "--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)) + } +} diff --git a/internal/frontend/cli/mcp_pnpm_test.go b/internal/frontend/cli/mcp_pnpm_test.go new file mode 100644 index 0000000000..1a3071add8 --- /dev/null +++ b/internal/frontend/cli/mcp_pnpm_test.go @@ -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/docs-mcp@1.2.0", "--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)) + } +} diff --git a/internal/frontend/serve/mcp_parse_docker_test.go b/internal/frontend/serve/mcp_parse_docker_test.go new file mode 100644 index 0000000000..3325a0d897 --- /dev/null +++ b/internal/frontend/serve/mcp_parse_docker_test.go @@ -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) + } +} diff --git a/internal/frontend/serve/mcp_parse_go_test.go b/internal/frontend/serve/mcp_parse_go_test.go new file mode 100644 index 0000000000..69a4819cea --- /dev/null +++ b/internal/frontend/serve/mcp_parse_go_test.go @@ -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/reasonix-server@v1.2.3 --stdio", "reasonix-server"}, + {"go run -tags integration example.com/tools/reasonix-server@v1.2.3 --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) + } +} diff --git a/internal/frontend/serve/mcp_parse_launcher_test.go b/internal/frontend/serve/mcp_parse_launcher_test.go new file mode 100644 index 0000000000..d6ae0583f5 --- /dev/null +++ b/internal/frontend/serve/mcp_parse_launcher_test.go @@ -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) + } +} diff --git a/internal/frontend/serve/mcp_parse_npm_test.go b/internal/frontend/serve/mcp_parse_npm_test.go new file mode 100644 index 0000000000..5f3da072b7 --- /dev/null +++ b/internal/frontend/serve/mcp_parse_npm_test.go @@ -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/docs-mcp@1.2.0 --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) + } +} diff --git a/internal/frontend/serve/mcp_parse_pnpm_test.go b/internal/frontend/serve/mcp_parse_pnpm_test.go new file mode 100644 index 0000000000..c08932cc47 --- /dev/null +++ b/internal/frontend/serve/mcp_parse_pnpm_test.go @@ -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/docs-mcp@1.2.0 --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) + } +}