mirror of
https://github.com/vercel-labs/fx.git
synced 2026-10-28 10:15:17 +08:00
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:
6 files changed
+68
-39
No files matched your search
+8
-5
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user