diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 315a49c4c..ee082f7c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -203,8 +203,8 @@ array. Workspace entries are always optional and never load stored credentials. Approved workspace `command`, `args`, `env`, and HTTP header values expand `${VAR}` and `${VAR:-default}` from the fx process environment. Pending and rejected entries do not read environment values. Missing required variables -leave an approved server unloaded and appear in `/mcp list` without exposing -values. +leave an approved server unloaded and appear in the `/mcp` and `/mcp list` +menu without exposing values. Interactive sessions keep pending workspace servers disconnected and request project trust before any project-defined process or network effect. Pending @@ -230,7 +230,9 @@ assertions. Servers validate their tool arguments and results. The interactive surface supports: -* `/mcp list` +* `/mcp` + +* `/mcp list` (opens the same server menu) * `/mcp resource list ` @@ -330,8 +332,9 @@ MCP capability to a child. Server-filtered searches, selected tools, and feature operations activate only their target; a broad search activates the broader catalog. Each server owns its startup and recovery progress. Connection deadlines cover discovery, fallback, and restarts together. Interactive authentication and -logout change only the affected connection. `/mcp list` renders a bounded, secret-free health -snapshot. The interactive menu refreshes that view while it is open. +logout change only the affected connection. `/mcp` and `/mcp list` open the same +bounded, secret-free menu, which refreshes its live health snapshot while open. +Noninteractive `fx mcp list` renders the health snapshot to stdout. Search and explicit selection share bounded schema publication. Definitions are checked against their runtime, connection, catalog, and credential generations diff --git a/src/core/app/app_commands.zig b/src/core/app/app_commands.zig index 9727b6482..bd9f39b39 100644 --- a/src/core/app/app_commands.zig +++ b/src/core/app/app_commands.zig @@ -1258,7 +1258,8 @@ pub fn Handlers(comptime App: type) type { fn commandHandleMcp(ctx: *anyopaque, rest: []const u8) !void { const app: *App = @ptrCast(@alignCast(ctx)); - if (std.mem.trim(u8, rest, " \t").len == 0 and + const command = std.mem.trim(u8, rest, " \t"); + if ((command.len == 0 or std.mem.eql(u8, command, "list")) and comptime @hasDecl(App, "openMcpMenu")) { closeModelMenuIfPresent(app); @@ -4215,8 +4216,10 @@ const McpCommandFakeApp = struct { }; alloc: std.mem.Allocator, + shell: transcript_runtime.TranscriptRuntime = .{}, notice_body: std.ArrayList(u8) = .empty, list_count: usize = 0, + menu_open_count: usize = 0, reload_count: usize = 0, notice_count: usize = 0, last_topic: ?[]const u8 = null, @@ -4230,9 +4233,14 @@ const McpCommandFakeApp = struct { menu_authentication_completions: usize = 0, fn deinit(self: *McpCommandFakeApp) void { + self.shell.deinit(self.alloc); self.notice_body.deinit(self.alloc); } + fn openMcpMenu(self: *McpCommandFakeApp) !void { + self.menu_open_count += 1; + } + fn mcpCommandProvider(_: *const McpCommandFakeApp) mcp_command_provider.Provider { return .{ .handle_fn = handleMcpCommand }; } @@ -5244,6 +5252,18 @@ test "copy command reports missing replies and host failures" { try std.testing.expectEqual(@as(usize, 2), app.copy_calls); } +test "MCP list opens the menu without writing a transcript notice" { + var app = McpCommandFakeApp{ .alloc = std.testing.allocator }; + defer app.deinit(); + + try Handlers(McpCommandFakeApp).commandHandleMcp(@ptrCast(&app), " list "); + + try std.testing.expectEqual(@as(usize, 1), app.menu_open_count); + try std.testing.expectEqual(@as(usize, 0), app.list_count); + try std.testing.expectEqual(@as(usize, 0), app.notice_count); + try std.testing.expect(app.shell.render_requests.hasReason(.footer)); +} + test "app_commands renders transactional status for explicit MCP reload" { var app = McpCommandFakeApp{ .alloc = std.testing.allocator }; defer app.deinit(); diff --git a/tests/e2e/mcp-auth.test.ts b/tests/e2e/mcp-auth.test.ts index f3fa07d0a..42c7c7c6a 100644 --- a/tests/e2e/mcp-auth.test.ts +++ b/tests/e2e/mcp-auth.test.ts @@ -1310,9 +1310,9 @@ describe("MCP remote authentication lifecycle", () => { await tui.sendText("/mcp reload"); await tui.waitForText("MCP configuration reloaded", 15_000); await tui.sendText("/mcp list"); - let pane = await tui.waitForText("MCP health (2 servers):", 10_000); - expect(pane).toMatch(/fixture[\s\S]{0,240}state=ready/); - expect(pane).toMatch(/canary[\s\S]{0,240}state=ready/); + let pane = await tui.waitForText("[Servers]", 10_000); + expect(pane).toMatch(/fixture[\s\S]{0,120}Ready/); + expect(pane).toMatch(/canary[\s\S]{0,120}Ready/); const stored = JSON.parse(readFileSync(credentialPath, "utf8")); expect(stored.credentials).toHaveLength(1); @@ -1329,9 +1329,9 @@ describe("MCP remote authentication lifecycle", () => { }); await tui.waitForComposer(15_000); await tui.sendText("/mcp list"); - pane = await tui.waitForText("MCP health (2 servers):", 10_000); - expect(pane).toMatch(/fixture[\s\S]{0,240}state=ready/); - expect(pane).toMatch(/canary[\s\S]{0,240}state=ready/); + pane = await tui.waitForText("[Servers]", 10_000); + expect(pane).toMatch(/fixture[\s\S]{0,120}Ready/); + expect(pane).toMatch(/canary[\s\S]{0,120}Ready/); } finally { canary.stop(); } @@ -2316,7 +2316,10 @@ describe("MCP remote authentication lifecycle", () => { 5_000, ); await tui.sendText("/mcp list"); - await tui.waitForText("auth=required", 5_000); + const menu = await tui.waitForText("Needs authentication", 5_000); + expect(menu).toContain("fixture"); + await tui.sendKeys("Escape"); + await tui.waitForPane((pane) => !pane.includes("[Servers]"), 5_000); await tui.sendText("/mcp reload"); await tui.waitForText("MCP reconnection started.", 5_000); @@ -2630,12 +2633,12 @@ describe("MCP remote authentication lifecycle", () => { expect(summary).toContain("Ready"); await tui.sendKeys("Escape"); await tui.waitForPane((pane) => !pane.includes("[Servers]"), 5_000); + const beforeListMenu = await tui.captureFullScrollback(); await tui.sendText("/mcp list"); - const status = await tui.waitForText("auth=authenticated", 5_000); + const status = await tui.waitForText("[Servers]", 5_000); + expect(status).toContain("fixture"); + expect(status).toContain("Ready"); expect(status).not.toContain(ACCESS_REFRESHED); - const beforeLogoutMenu = await tui.captureFullScrollback(); - await tui.sendText("/mcp"); - await tui.waitForText("[Servers]", 5_000); await tui.sendKeys("Enter"); await tui.waitForText("Profile ยท ~/.fx/mcp.json", 5_000); await tui.sendKeys("L"); @@ -2646,7 +2649,7 @@ describe("MCP remote authentication lifecycle", () => { expect(auth.revocations).toBe(2); await tui.sendKeys("Escape"); await tui.waitForPane((pane) => !pane.includes("[Servers]"), 5_000); - expect(await tui.captureFullScrollback()).toBe(beforeLogoutMenu); + expect(await tui.captureFullScrollback()).toBe(beforeListMenu); await tui.kill(); tui = null; diff --git a/tests/e2e/mcp-http.test.ts b/tests/e2e/mcp-http.test.ts index 306e74aea..a5c2dcd82 100644 --- a/tests/e2e/mcp-http.test.ts +++ b/tests/e2e/mcp-http.test.ts @@ -472,11 +472,12 @@ describe("modern MCP Streamable HTTP", () => { expect(saved).toContain("MCP reconnection started"); await tui.waitForText("MCP configuration reloaded successfully.", 15_000); await tui.sendText("/mcp list"); - const health = await tui.waitForText("MCP health (1 server):", 10_000); - expect(health).toMatch(/prisma[\s\S]{0,240}transport=http state=ready/); - expect(health).toContain( - "negotiated_name=modern-http-fixture negotiated_version=unavailable protocol=2026-07-28", - ); + const menu = await tui.waitForText("[Servers]", 10_000); + expect(menu).toMatch(/prisma[\s\S]{0,120}Ready/); + await tui.sendKeys("Enter"); + const details = await tui.waitForText("Protocol", 5_000); + expect(details).toMatch(/Transport\s+http/); + expect(details).toMatch(/Protocol\s+2026-07-28/); const profile = JSON.parse( readFileSync(join(root.home, ".fx", "mcp.json"), "utf8"), diff --git a/tests/e2e/mcp-legacy-remote.test.ts b/tests/e2e/mcp-legacy-remote.test.ts index bb289c236..c20d31092 100644 --- a/tests/e2e/mcp-legacy-remote.test.ts +++ b/tests/e2e/mcp-legacy-remote.test.ts @@ -223,7 +223,7 @@ describe("version-scoped legacy MCP remote transports", () => { }, 30_000); } - test.skipIf(!tmuxAvailable())( + test( "legacy list-change health reports an installed listener and lazy feature counts truthfully", async () => { streamable = startLegacyStreamableHttpFixture("2025-11-25", { @@ -235,22 +235,19 @@ describe("version-scoped legacy MCP remote transports", () => { gateway = startFakeGateway([fakeGatewayFinalText("unused")], { models: [{ id: MODEL, type: "language", tags: ["tool-use"] }], }); - tui = await TmuxSession.create({ - isolated: true, - cwd: root.workspace, - width: 160, - height: 34, - env: fixtureEnv(root, gateway), - }); - await tui.waitForComposer(15_000); - await tui.sendText("/mcp list"); - const pane = await tui.waitForText("MCP health (1 server)", 10_000); - expect(pane).toContain("protocol=2025-11-25"); - expect(pane).toContain( + const result = await runFx(["mcp", "list", "--connect"], { + cwd: root.workspace, + env: fixtureEnv(root, gateway), + timeoutMs: 30_000, + }); + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("protocol=2025-11-25"); + expect(result.stdout).toContain( "tools=1 resources=unknown templates=unknown prompts=unknown", ); - expect(pane).toContain("subscription=active"); + expect(result.stdout).toContain("subscription=active"); expect(gateway.requests).toHaveLength(0); }, 30_000, diff --git a/tests/e2e/tui-startup.test.ts b/tests/e2e/tui-startup.test.ts index fd0e52d36..43403d70e 100644 --- a/tests/e2e/tui-startup.test.ts +++ b/tests/e2e/tui-startup.test.ts @@ -352,9 +352,14 @@ describe.skipIf(SKIP_TMUX)("tui: MCP startup", () => { expect(summary).toContain("Connecting"); await session.sendKeys("Escape"); await session.waitForPane((pane) => !pane.includes("[Servers]"), 5_000); + const beforeListMenu = await session.captureFullScrollback(); await session.sendText("/mcp list"); - const status = await session.waitForText("state=connecting", 5_000); - expect(status).toContain("pending source=profile scope=profile policy=optional"); + const listMenu = await session.waitForText("[Servers]", 5_000); + expect(listMenu).toContain("pending"); + expect(listMenu).toContain("Connecting"); + await session.sendKeys("Escape"); + await session.waitForPane((pane) => !pane.includes("[Servers]"), 5_000); + expect(await session.captureFullScrollback()).toBe(beforeListMenu); await session.sendText("/quit"); expect(await session.waitForSessionEnd(5_000)).toBe(true);