Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -833,10 +833,15 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
RunCompletionWarning: func() string {
return scratchFileWarning(workspaceRoot, scratchBaseline)
},
Registry: registry,
SessionStore: deps.newSessionStore(),
SandboxStore: sandboxStore,
MCPConfig: mcpConfig,
Registry: registry,
SessionStore: deps.newSessionStore(),
SandboxStore: sandboxStore,
MCPConfig: mcpConfig,
// The panel needs the failures too. A startup warning on stderr scrolls
// away behind the first screen of output, so /mcp is where a user goes
// to ask what is actually running — it should not answer from config
// alone and report a server that never connected as enabled.
MCPSkipped: mcpRuntime.Skipped(),
MCPPermissionStore: mcpPermissionStore,
MCPTokenStore: mcpTokenStore,
MCPCommand: func(ctx context.Context, args []string) tui.MCPCommandResult {
Expand Down
72 changes: 72 additions & 0 deletions internal/cli/app_mcp_skipped_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package cli

import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"

"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tui"
)

type skippingMCPRuntime struct {
skipped []mcp.SkippedServer
}

func (r skippingMCPRuntime) Close() error { return nil }
func (r skippingMCPRuntime) Skipped() []mcp.SkippedServer { return r.skipped }

// Startup already knows which servers failed — it prints a warning about each.
// That warning is gone by the time anyone looks, so the same set has to reach
// the TUI, which is where /mcp answers "what is actually running".
func TestRunPassesSkippedMCPServersToTheTUI(t *testing.T) {
var stdout, stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
projectConfigPath := filepath.Join(cwd, ".zero", "config.json")
if err := os.MkdirAll(filepath.Dir(projectConfigPath), 0o700); err != nil {
t.Fatalf("create project config parent: %v", err)
}
if err := os.WriteFile(projectConfigPath, []byte("{}"), 0o600); err != nil {
t.Fatalf("write project config: %v", err)
}
var launchedOptions tui.Options

exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 12}, nil
},
userConfigPath: func() (string, error) {
return filepath.Join(t.TempDir(), "zero", "config.json"), nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return skippingMCPRuntime{skipped: []mcp.SkippedServer{
{Name: "docs", Err: errors.New("connection refused")},
}}, nil
},
runTUI: func(_ context.Context, options tui.Options) int {
launchedOptions = options
return 0
},
})

if exitCode != 0 {
t.Fatalf("exit code = %d, want 0 (stderr: %s)", exitCode, stderr.String())
}
if len(launchedOptions.MCPSkipped) != 1 ||
launchedOptions.MCPSkipped[0].Name != "docs" {
t.Fatalf("MCPSkipped = %#v, want the failure startup recorded", launchedOptions.MCPSkipped)
}
// The stderr warning stays: it is what a non-interactive user sees, and the
// panel is an addition to it, not a replacement.
if !strings.Contains(stderr.String(), "docs") {
t.Errorf("startup no longer warns about the skipped server: %q", stderr.String())
}
}
1 change: 1 addition & 0 deletions internal/tui/command_views.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ func (m *model) refreshMCPViewState() {
PermissionStore: m.mcpPermissionStore,
PermissionMode: string(m.permissionMode),
TokenStore: m.mcpTokenStore,
Skipped: m.mcpSkipped,
})
m.mcpViewStateReady = true
}
Expand Down
139 changes: 139 additions & 0 deletions internal/tui/mcp_failed_state_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package tui

import (
"context"
"errors"
"strings"
"testing"

"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/mcp"
)

// The panel has to report what is running, not what is written down. MCP
// registration is best-effort — a server that fails to start is recorded and
// startup continues — so without the skipped set the panel calls a server that
// never connected "enabled" and the user has no way to tell from here why its
// tools are missing.
func TestBuildMCPViewStateReportsServersThatFailedToStart(t *testing.T) {
cfg := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
"linear": {Type: "http", URL: "https://linear.example/mcp"},
"offline": {Type: "http", URL: "https://offline.example/mcp", Disabled: true},
}}
skipped := []mcp.SkippedServer{
{Name: "docs", Err: errors.New(`exec: "docs-mcp": executable file not found in $PATH`)},
// Disabled servers are never started, so one should not appear here.
// Assert the precedence anyway: if it ever does, the user turned this
// server off and "failed" would be a lie.
{Name: "offline", Err: errors.New("should not be reported")},
}

state := BuildMCPViewState(MCPStateOptions{Config: cfg, Skipped: skipped})

byName := make(map[string]MCPServerView, len(state.Servers))
for _, server := range state.Servers {
byName[server.Name] = server
}
if got := byName["docs"]; got.State != "failed" ||
got.Error != `exec: "docs-mcp": executable file not found in $PATH` {
t.Errorf("failed server = %#v, want state \"failed\" carrying the recorded reason", got)
}
if got := byName["linear"]; got.State != "enabled" || got.Error != "" {
t.Errorf("healthy server = %#v, want it left as enabled with no error", got)
}
if got := byName["offline"]; got.State != "disabled" || got.Error != "" {
t.Errorf("disabled server = %#v, want disabled to win over a recorded failure", got)
}
}

// The reason is rendered from an error the server produced, so it is untrusted
// text that can carry whatever the transport echoed back — including the
// credential Zero sent it.
func TestBuildMCPViewStateRedactsTheFailureReason(t *testing.T) {
cfg := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"linear": {Type: "http", URL: "https://linear.example/mcp"},
}}
state := BuildMCPViewState(MCPStateOptions{
Config: cfg,
Skipped: []mcp.SkippedServer{{
Name: "linear",
Err: errors.New("handshake rejected: Authorization: Bearer sk-live-abcdef0123456789abcdef"),
}},
})

reason := state.Servers[0].Error
if strings.Contains(reason, "sk-live-abcdef0123456789abcdef") {
t.Fatalf("failure reason leaked the bearer token: %q", reason)
}
if !strings.Contains(reason, "handshake rejected") {
t.Errorf("redaction ate the diagnostic part of the reason: %q", reason)
}
}

// A nil or blank error still means the server is not running. "failed" with
// nothing after it reads like a rendering bug, so fall back to a plain
// statement rather than an empty line.
func TestBuildMCPViewStateFallsBackWhenTheFailureHasNoMessage(t *testing.T) {
cfg := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}
for name, err := range map[string]error{
"nil error": nil,
"blank error": errors.New(" "),
} {
t.Run(name, func(t *testing.T) {
state := BuildMCPViewState(MCPStateOptions{
Config: cfg,
Skipped: []mcp.SkippedServer{{Name: "docs", Err: err}},
})
got := state.Servers[0]
if got.State != "failed" {
t.Fatalf("state = %q, want \"failed\" even without a message", got.State)
}
if strings.TrimSpace(got.Error) == "" {
t.Error("failed server rendered with no reason at all")
}
})
}
}

// The reason has to survive into the text the user actually reads.
func TestRenderMCPViewShowsTheFailureReason(t *testing.T) {
cfg := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}
state := BuildMCPViewState(MCPStateOptions{
Config: cfg,
Skipped: []mcp.SkippedServer{{Name: "docs", Err: errors.New("connection refused")}},
})

rendered := renderMCPView(state, 100)
if !strings.Contains(rendered, "failed") {
t.Errorf("panel does not say the server failed:\n%s", rendered)
}
if !strings.Contains(rendered, "connection refused") {
t.Errorf("panel does not show why it failed:\n%s", rendered)
}
if strings.Contains(rendered, "docs · enabled") {
t.Errorf("panel still calls the failed server enabled:\n%s", rendered)
}
}

// End to end through the model: what startup recorded is what /mcp reports.
// The wiring is the whole point — the builder can be correct while the panel
// still renders from a set nobody handed it.
func TestModelMCPPanelReportsStartupFailures(t *testing.T) {
m := newModel(context.Background(), Options{
MCPConfig: config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}},
MCPSkipped: []mcp.SkippedServer{
{Name: "docs", Err: errors.New("connection refused")},
},
})
panel := m.mcpText()
if !strings.Contains(panel, "failed") || !strings.Contains(panel, "connection refused") {
t.Errorf("/mcp panel did not carry the startup failure through:\n%s", panel)
}
}
29 changes: 26 additions & 3 deletions internal/tui/mcp_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/tools"
)

Expand All @@ -19,6 +20,11 @@ type MCPStateOptions struct {
PermissionMode string
PromptCount int
DeniedCount int
// Skipped are the servers registration could not start. Registration is
// best-effort so one unreachable server cannot stop Zero launching, which
// means a failure is recorded here rather than returned. Without it this
// panel reports configuration instead of reality.
Skipped []mcp.SkippedServer
}

type mcpServerNamedTool interface {
Expand All @@ -38,21 +44,37 @@ func BuildMCPViewState(options MCPStateOptions) MCPViewState {
}

return MCPViewState{
Servers: buildMCPServerViews(options.Config, toolCounts),
Servers: buildMCPServerViews(options.Config, toolCounts, options.Skipped),
Tools: toolViews,
Permissions: buildMCPPermissionSummary(options),
OAuth: buildMCPOAuthSummary(options.Config, options.TokenStore),
}
}

func buildMCPServerViews(cfg config.MCPConfig, toolCounts map[string]int) []MCPServerView {
func buildMCPServerViews(cfg config.MCPConfig, toolCounts map[string]int, skipped []mcp.SkippedServer) []MCPServerView {
failures := make(map[string]error, len(skipped))
for _, entry := range skipped {
failures[entry.Name] = entry.Err
}
names := sortedMCPServerNames(cfg)
servers := make([]MCPServerView, 0, len(names))
for _, name := range names {
raw := cfg.Servers[name]
state := "enabled"
if raw.Disabled {
message := ""
switch {
case raw.Disabled:
// Disabled wins: the user turned it off, so it was never expected to
// connect and reporting it as failed would be misleading.
state = "disabled"
default:
if err, ok := failures[name]; ok {
state = "failed"
message = redaction.ErrorMessage(err, redaction.Options{})
if strings.TrimSpace(message) == "" {
message = "server did not start"
}
}
}
servers = append(servers, MCPServerView{
Name: name,
Expand All @@ -61,6 +83,7 @@ func buildMCPServerViews(cfg config.MCPConfig, toolCounts map[string]int) []MCPS
Target: mcpServerTarget(raw),
Auth: strings.TrimSpace(raw.Auth),
ToolCount: toolCounts[name],
Error: message,
})
}
return servers
Expand Down
9 changes: 9 additions & 0 deletions internal/tui/mcp_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ type MCPServerView struct {
Target string
Auth string
ToolCount int
// Error explains a "failed" state. Empty for every other state.
Error string
}

type MCPToolView struct {
Expand Down Expand Up @@ -153,6 +155,13 @@ func mcpManagerServerLines(servers []MCPServerView) []string {
}
parts = append(parts, transport)
lines = append(lines, prefix+strings.Join(parts, " · "))
// The reason sits directly under the server rather than in the actions
// line, because "failed" on its own sends the reader to check their
// config when the answer is usually in the error: a missing binary, a
// refused connection, a bad token.
if reason := strings.TrimSpace(server.Error); reason != "" {
lines = append(lines, " "+reason)
}
if target := strings.TrimSpace(server.Target); target != "" {
lines = append(lines, " "+target)
}
Expand Down
2 changes: 2 additions & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ type model struct {
sessionStore *sessions.Store
sandboxStore *sandbox.GrantStore
mcpConfig config.MCPConfig
mcpSkipped []internalmcp.SkippedServer
mcpPermissionStore *internalmcp.PermissionStore
mcpTokenStore *internalmcp.TokenStore
mcpCommand func(context.Context, []string) MCPCommandResult
Expand Down Expand Up @@ -882,6 +883,7 @@ func newModel(ctx context.Context, options Options) model {
sessionStore: sessionStore,
sandboxStore: sandboxStore,
mcpConfig: options.MCPConfig,
mcpSkipped: options.MCPSkipped,
mcpPermissionStore: options.MCPPermissionStore,
mcpTokenStore: options.MCPTokenStore,
mcpCommand: options.MCPCommand,
Expand Down
19 changes: 12 additions & 7 deletions internal/tui/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,18 @@ type Options struct {
SessionStore *sessions.Store
SandboxStore *sandbox.GrantStore
MCPConfig config.MCPConfig
MCPPermissionStore *mcp.PermissionStore
MCPTokenStore *mcp.TokenStore
MCPCommand func(context.Context, []string) MCPCommandResult
SandboxSetupCommand func(context.Context) SandboxSetupCommandResult
UsageTracker *usage.Tracker
SessionCompactor SessionCompactor
PrService *PrService
// MCPSkipped carries the servers that failed to start, so /mcp can report
// what is actually running rather than what is configured. Startup already
// records these; without them the panel derives state from config alone and
// shows a server that never connected as "enabled" with no explanation.
MCPSkipped []mcp.SkippedServer
MCPPermissionStore *mcp.PermissionStore
MCPTokenStore *mcp.TokenStore
MCPCommand func(context.Context, []string) MCPCommandResult
SandboxSetupCommand func(context.Context) SandboxSetupCommandResult
UsageTracker *usage.Tracker
SessionCompactor SessionCompactor
PrService *PrService

AgentOptions agent.Options
// LoadSkills returns the installed skills (default skills dir merged with any
Expand Down
Loading