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.
This commit is contained in:
Pranit committed 2026-10-23 19:27:25 -04:00
1 parent 2da31b5f12
commit adb809fc4b
4 files changed
+112 -2

No files matched your search

+16
View File
@@ -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,
+39 -1
View File
@@ -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();
+14 -1
View File
@@ -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,
+43
View File
@@ -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,
);
});