Open MCP list in the interactive menu

Route /mcp list through the existing Servers menu while preserving diagnostic output for noninteractive fx mcp list.
This commit is contained in:
Pranit committed 2026-09-22 14:59:43 -04:00
1 parent 10bf719b6b
commit 399f2b87ac
6 files changed
+68 -39

No files matched your search

+8 -5
View File
@@ -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 <server>`
@@ -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
+21 -1
View File
@@ -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();
+15 -12
View File
@@ -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;
+6 -5
View File
@@ -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"),
+11 -14
View File
@@ -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,
+7 -2
View File
@@ -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);