From adb809fc4be5ce012fcd131ac3ca6c6ffefbd8fb Mon Sep 17 00:00:00 2001 From: Pranit Date: Fri, 23 Oct 2026 19:27:25 -0400 Subject: [PATCH] Limit an instance's MCP servers to its profile's An instance launched in a profile that names its MCP servers could still find and call every server's tools. Its tool calls now carry the profile's server list, and the MCP host finds and calls only the tools of those servers. --- src/core/app/app_agent_runtime.zig | 16 ++++++++++ src/core/mcp_host/model.zig | 40 ++++++++++++++++++++++++- src/core/tooling/tool_mcp_runtime.zig | 15 +++++++++- tests/e2e/tui-host-instances.test.ts | 43 +++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 2 deletions(-) diff --git a/src/core/app/app_agent_runtime.zig b/src/core/app/app_agent_runtime.zig index 354045d0f..9cdb5d0be 100644 --- a/src/core/app/app_agent_runtime.zig +++ b/src/core/app/app_agent_runtime.zig @@ -1,5 +1,6 @@ const std = @import("std"); const agents = @import("../agents/agents.zig"); +const profiles = @import("../profiles/profiles.zig"); const agent_runtime = @import("../agent/agent_runtime.zig"); const runtime_deps = @import("../agent/runtime/deps.zig"); const auto_classifier = @import("../permissions/auto_classifier.zig"); @@ -491,10 +492,25 @@ pub fn Runtime(comptime App: type) type { } ctx.model_capability_resolver = app_callbacks.Bindings(App).modelCapabilityResolver(agent); ctx.model_override_resolver = app_callbacks.Bindings(App).modelOverrideResolver(agent); + // An instance whose profile names its MCP servers uses only those. + // A subagent's run replaces this with its own view below. + if (comptime agents.hasAgentField(App, "profile") and agents.hasAgentField(App, "role")) { + if (agent.role == .instance and profiles.setsMcpServers(&agent.profile.profile)) { + ctx.mcp_access = .{ .servers = .{ .context = @ptrCast(&agent.profile.profile), .allows_fn = profileAllowsMcpServer } }; + } + } if (childRun(agent)) |run| applyChildToolContext(app, &ctx, run); return ctx; } + /// Whether the instance profile at `raw` allows MCP server `server`. + /// The profile is fixed once the instance launches, so worker threads + /// read it as it is. + fn profileAllowsMcpServer(raw: *const anyopaque, server: []const u8) bool { + const profile: *const profiles.Profile = @ptrCast(@alignCast(raw)); + return profiles.allowsMcpServer(profile, server); + } + fn respondToMcpInput( raw_ctx: *anyopaque, alloc: Allocator, diff --git a/src/core/mcp_host/model.zig b/src/core/mcp_host/model.zig index fd15c80bb..1946b2121 100644 --- a/src/core/mcp_host/model.zig +++ b/src/core/mcp_host/model.zig @@ -478,7 +478,7 @@ pub const Catalog = struct { /// view from another host. Checked before a list is loaded. fn mayAllow(c: *const Catalog, access: Access) bool { return switch (access) { - .unrestricted => true, + .unrestricted, .servers => true, .disabled => false, .scoped => |scope| scope.captured.runtime_generation == c.generation, }; @@ -493,6 +493,7 @@ pub const Catalog = struct { return switch (access) { .scoped => |scope| scope.captured.tool(name) != null or scope.captured.server(c.runtime.servers()[server].name) != null, + .servers => |filter| filter.allows(c.runtime.servers()[server].name), else => true, }; } @@ -885,6 +886,43 @@ test "a subagent made before its parent's server starts may use the server's too try testing.expectError(error.McpAccessDenied, c.callTool(a, "mcp_fake_echo", "{}", 4096, .{ .access = stale })); } +test "an instance's server filter allows the tools of its servers and refuses the rest" { + var f: Fixture = undefined; + try f.init(); + defer f.deinit(); + const configs = [_]mcp_contract.McpServerConfig{.{ .name = "fake", .command = f.server }}; + var r: Runtime = undefined; + try r.start(testing.allocator, testing.io, &configs, &f.inherited, f.options()); + defer r.deinit(); + var c: Catalog = undefined; + try c.init(testing.allocator, testing.io, &r, .{}); + defer c.deinit(); + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const Named = struct { + fn allows(raw: *const anyopaque, server: []const u8) bool { + const wanted: *const []const u8 = @ptrCast(@alignCast(raw)); + return std.mem.eql(u8, wanted.*, server); + } + }; + const fake_name: []const u8 = "fake"; + const other_name: []const u8 = "other"; + const allowed: Access = .{ .servers = .{ .context = @ptrCast(&fake_name), .allows_fn = Named.allows } }; + const refused: Access = .{ .servers = .{ .context = @ptrCast(&other_name), .allows_fn = Named.allows } }; + try testing.expect(c.hasTool("mcp_fake_echo", allowed)); + const result = (try c.callTool(a, "mcp_fake_echo", "{}", 4096, .{ .access = allowed })).?; + try testing.expectEqual(tool_mcp_runtime.CallStatus.success, result.status); + + // A server the filter leaves out is neither found nor called. + try testing.expect(!c.hasTool("mcp_fake_echo", refused)); + try testing.expectError(error.McpAccessDenied, c.callTool(a, "mcp_fake_echo", "{}", 4096, .{ .access = refused })); + const query = try lexical_relevance.prepare("echo"); + const found = try c.searchTools(a, .{ .query = &query, .kind = .mcp }, .{}, .{}, refused, null); + try testing.expectEqual(@as(usize, 0), found.selected_tools.len); +} + test "an MCP server's tools reach the model under fx's names" { var f: Fixture = undefined; try f.init(); diff --git a/src/core/tooling/tool_mcp_runtime.zig b/src/core/tooling/tool_mcp_runtime.zig index 146cff3f6..5eabec72d 100644 --- a/src/core/tooling/tool_mcp_runtime.zig +++ b/src/core/tooling/tool_mcp_runtime.zig @@ -41,9 +41,22 @@ const LiveViewProvider = struct { } }; +/// The MCP servers an agent may use, by name: an instance's profile. +pub const ServerFilter = struct { + context: *const anyopaque, + allows_fn: *const fn (*const anyopaque, []const u8) bool, + + pub fn allows(self: ServerFilter, server: []const u8) bool { + return self.allows_fn(self.context, server); + } +}; + pub const Access = union(enum) { unrestricted, disabled, + /// Every tool of a server the filter allows. The host, which knows each + /// tool's server, decides. + servers: ServerFilter, scoped: struct { captured: *const access_policy.View, admission_authority_generation: u64, @@ -53,7 +66,7 @@ pub const Access = union(enum) { pub fn allowsTool(self: Access, runtime_generation: u64, name: []const u8) bool { return switch (self) { - .unrestricted => true, + .unrestricted, .servers => true, .disabled => false, .scoped => |scope| scope.captured.runtime_generation == runtime_generation and scope.captured.tool(name) != null, diff --git a/tests/e2e/tui-host-instances.test.ts b/tests/e2e/tui-host-instances.test.ts index 0238586d8..228a237ba 100644 --- a/tests/e2e/tui-host-instances.test.ts +++ b/tests/e2e/tui-host-instances.test.ts @@ -792,4 +792,47 @@ describe.skipIf(!tmuxAvailable())("TUI host instances", () => { TIMEOUT, ); + + test( + "an instance's profile decides which MCP servers it can use", + async () => { + const fixture = join(import.meta.dirname, "fixtures", "mcp-modern-stdio.mjs"); + // Select the fixture's tool, call it, then finish. + const respond = (body: string): Response => { + const request = JSON.parse(body) as { prompt?: Array<{ role?: string }> }; + const results = (request.prompt ?? []).filter((message) => message.role === "tool").length; + if (results === 0) return call("select_mcp", "mcp_select_tool", { name: "mcp_fixture_echo" }); + if (results === 1) return call("call_mcp", "mcp_fixture_echo", { text: "hello" }); + return text("mcp_done", "TREE_MCP_DONE"); + }; + for (const [profile, servers, reaches] of [["nomcp", [], false], ["withmcp", ["fixture"], true]] as const) { + const home = createIsolatedTestHome(); + const workspace = realpathSync(mkdtempSync(join(tmpdir(), "fx-host-mcp-"))); + try { + mkdirSync(join(home, ".fx"), { recursive: true }); + writeFileSync(join(home, ".fx", "mcp.json"), JSON.stringify({ + // The shell can ask a server for input, and the fixture checks it says so. + mcp: { fixture: { type: "local", command: [process.execPath, fixture], enabled: true, environment: { FX_MCP_EXPECT_ELICITATION: "both" } } }, + })); + writeProfile(home, profile, { description: "Checks MCP access", mcp_servers: servers }); + const bodies = await withHost(respond, { + args: `--profile ${profile}`, + env: { FX_PERMISSION_MODE: "full-access" }, + reuse: { home, workspace }, + }, async ({ session }) => { + await session.sendText("TREE_MCP_USER call the fixture"); + await session.waitForText("TREE_MCP_DONE", 45_000); + }); + // The fixture's answer reaches the model only when the profile + // names its server. + expect((bodies.at(-1) ?? "").includes("MODERN_MCP_TOOL_RESULT")).toBe(reaches); + } finally { + cleanupIsolatedTestHome(home); + rmSync(workspace, { recursive: true, force: true }); + } + } + }, + TIMEOUT * 2, + ); + });