mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* feat: add MCP manager UX * fix: harden MCP manager UX * test: stabilize agent eval timeout on Windows * fix: preserve MCP server metadata * fix: clarify empty MCP view * feat: make MCP command a manager view * fix: make MCP manager usable in TUI * fix: make MCP manager selectable in TUI * fix: simplify MCP manager popup * feat: add searchable MCP marketplace * fix: address MCP review issues * fix: address MCP follow-up review * feat: add MCP setup wizard * feat: detect MCP setup requests in chat * fix: address MCP manager review feedback * fix: avoid hijacking MCP chat prompts * fix: persist MCP view cache fallback
156 lines
4.9 KiB
Go
156 lines
4.9 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
)
|
|
|
|
func TestRunMCPAddJSONRedactsEnvAndPreservesConfig(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
|
writeMCPCommandConfig(t, configPath, config.FileConfig{
|
|
ActiveProvider: "local",
|
|
MaxTurns: 42,
|
|
Providers: []config.ProviderProfile{{
|
|
Name: "local",
|
|
Model: "qwen3-coder:480b",
|
|
}},
|
|
Preferences: config.PreferencesConfig{FavoriteModels: []string{"qwen3-coder:480b"}},
|
|
MCP: config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
|
"existing": {Type: "stdio", Command: "existing-mcp"},
|
|
}},
|
|
})
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{
|
|
"mcp", "add", "docs",
|
|
"--json",
|
|
"--env", "DOCS_TOKEN=stdio-secret",
|
|
"--", "docs-mcp", "--port", "123",
|
|
}, &stdout, &stderr, appDeps{
|
|
userConfigPath: func() (string, error) { return configPath, nil },
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
|
}
|
|
if output := stdout.String(); strings.Contains(output, "stdio-secret") {
|
|
t.Fatalf("JSON stdout leaked secret value: %s", output)
|
|
}
|
|
var payload struct {
|
|
Server config.MCPServerConfig `json:"server"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
|
|
}
|
|
if got := payload.Server.Env["DOCS_TOKEN"]; got != "[REDACTED]" {
|
|
t.Fatalf("JSON env redaction = %q, want [REDACTED]", got)
|
|
}
|
|
|
|
cfg := readMCPCommandConfig(t, configPath)
|
|
if cfg.ActiveProvider != "local" || cfg.MaxTurns != 42 || len(cfg.Providers) != 1 {
|
|
t.Fatalf("non-MCP config was not preserved: %#v", cfg)
|
|
}
|
|
if got := cfg.Preferences.FavoriteModels; len(got) != 1 || got[0] != "qwen3-coder:480b" {
|
|
t.Fatalf("preferences were not preserved: %#v", got)
|
|
}
|
|
if _, ok := cfg.MCP.Servers["existing"]; !ok {
|
|
t.Fatalf("existing MCP server was not preserved: %#v", cfg.MCP.Servers)
|
|
}
|
|
added := cfg.MCP.Servers["docs"]
|
|
if got := added.Env["DOCS_TOKEN"]; got != "stdio-secret" {
|
|
t.Fatalf("persisted env secret = %q, want original value", got)
|
|
}
|
|
}
|
|
|
|
func TestRunMCPAddJSONRedactsHeaders(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{
|
|
"mcp", "add", "docs",
|
|
"--json",
|
|
"--header", "Authorization=Bearer header-secret",
|
|
"--url", "https://example.com/mcp",
|
|
}, &stdout, &stderr, appDeps{
|
|
userConfigPath: func() (string, error) { return configPath, nil },
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
|
}
|
|
if output := stdout.String(); strings.Contains(output, "header-secret") {
|
|
t.Fatalf("JSON stdout leaked header value: %s", output)
|
|
}
|
|
var payload struct {
|
|
Server config.MCPServerConfig `json:"server"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
|
|
}
|
|
if got := payload.Server.Headers["Authorization"]; got != "[REDACTED]" {
|
|
t.Fatalf("JSON header redaction = %q, want [REDACTED]", got)
|
|
}
|
|
|
|
cfg := readMCPCommandConfig(t, configPath)
|
|
added := cfg.MCP.Servers["docs"]
|
|
if got := added.Headers["Authorization"]; got != "Bearer header-secret" {
|
|
t.Fatalf("persisted header secret = %q, want original value", got)
|
|
}
|
|
}
|
|
|
|
func TestRunMCPAddRejectsTransportSpecificSecrets(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
args []string
|
|
want string
|
|
}{
|
|
{
|
|
name: "http rejects env",
|
|
args: []string{"mcp", "add", "remote", "--url", "https://example.com/mcp", "--env", "REMOTE_TOKEN=secret"},
|
|
want: "env is only supported for stdio transport",
|
|
},
|
|
{
|
|
name: "sse rejects env",
|
|
args: []string{"mcp", "add", "events", "--type", "sse", "--url", "https://example.com/sse", "--env", "SSE_TOKEN=secret"},
|
|
want: "env is only supported for stdio transport",
|
|
},
|
|
{
|
|
name: "stdio rejects header",
|
|
args: []string{"mcp", "add", "local", "--header", "Authorization=Bearer secret", "--", "local-mcp"},
|
|
want: "headers are only supported for http or sse transports",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
configPathCalled := false
|
|
|
|
exitCode := runWithDeps(tc.args, &stdout, &stderr, appDeps{
|
|
userConfigPath: func() (string, error) {
|
|
configPathCalled = true
|
|
return filepath.Join(t.TempDir(), "zero", "config.json"), nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
|
}
|
|
if configPathCalled {
|
|
t.Fatal("user config path was resolved after invalid MCP add arguments")
|
|
}
|
|
if !strings.Contains(stderr.String(), tc.want) {
|
|
t.Fatalf("stderr missing %q:\n%s", tc.want, stderr.String())
|
|
}
|
|
if strings.Contains(stdout.String()+stderr.String(), "secret") {
|
|
t.Fatalf("output leaked secret value:\nstdout=%s\nstderr=%s", stdout.String(), stderr.String())
|
|
}
|
|
})
|
|
}
|
|
}
|