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