diff --git a/packages/core/src/mcp/instructions.ts b/packages/core/src/mcp/instructions.ts index e41a108bc35..d0cf45ec6dc 100644 --- a/packages/core/src/mcp/instructions.ts +++ b/packages/core/src/mcp/instructions.ts @@ -11,16 +11,20 @@ import { Instructions } from "../instructions/index" const Summary = Schema.Struct({ server: Schema.String, instructions: Schema.String, + codemode: Schema.optionalKey(Schema.Literal(false)), }) type Summary = typeof Summary.Type const entries = (servers: ReadonlyArray) => - servers.flatMap((server) => [ - ` `, - ` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.namespace(server.server))}]\`.`, - ...server.instructions.split("\n").map((line) => ` ${line}`), - " ", - ]) + servers.flatMap((server) => { + const result = [` `] + if (server.codemode !== false) + result.push( + ` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.namespace(server.server))}]\`.`, + ) + result.push(...server.instructions.split("\n").map((line) => ` ${line}`), " ") + return result + }) const render = (servers: ReadonlyArray) => ["", ...entries(servers), ""].join("\n") @@ -30,7 +34,7 @@ const update = (previous: ReadonlyArray, current: ReadonlyArray server.server, - (before, after) => before.instructions !== after.instructions, + (before, after) => before.instructions !== after.instructions || before.codemode !== after.codemode, ) // Additions and removals render as small deltas; anything else restates the full list. if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0)) @@ -76,21 +80,29 @@ export const layer = Layer.effect( removed: () => "MCP server instructions are no longer available.", }, }) - if (PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny") - return source(Instructions.removed) const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], { concurrency: "unbounded", }) + const canExecute = PermissionV2.evaluate("execute", "*", agent.permissions).effect !== "deny" // Instructions are useful only when this agent can reach at least one server tool. const visible = instructions - .filter((item) => { + .flatMap((item) => { const owned = tools.filter((tool) => tool.server === item.server) - return owned.some( - (tool) => - PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny", + const codemode = owned[0]?.codemode !== false + if (codemode && !canExecute) return [] + if ( + !owned.some( + (tool) => + PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny", + ) ) + return [] + return [ + codemode + ? { server: item.server, instructions: item.instructions } + : { server: item.server, instructions: item.instructions, codemode: false as const }, + ] }) - .map((item) => ({ server: item.server, instructions: item.instructions })) .toSorted((a, b) => a.server.localeCompare(b.server)) return source(visible.length === 0 ? Instructions.removed : visible) }), diff --git a/packages/core/test/mcp-instructions.test.ts b/packages/core/test/mcp-instructions.test.ts index 14e0e671f09..77b49363548 100644 --- a/packages/core/test/mcp-instructions.test.ts +++ b/packages/core/test/mcp-instructions.test.ts @@ -93,6 +93,60 @@ describe("McpInstructions", () => { ), ) + it.effect("keeps MCP instructions when Code Mode is disabled and execute is denied", () => + Effect.gen(function* () { + const service = yield* McpInstructions.Service + const generation = yield* service + .load(selection([{ action: "execute", resource: "*", effect: "deny" }])) + .pipe(Effect.flatMap(readInitial)) + + expect(generation.text).toBe( + [ + "", + ' ', + " Alpha instructions", + " ", + "", + ].join("\n"), + ) + }).pipe( + Effect.provide( + layer( + () => [instructions("alpha", "Alpha instructions")], + () => [new MCP.Tool({ server: MCP.ServerName.make("alpha"), name: "search", codemode: false })], + ), + ), + ), + ) + + it.effect("restates guidance when Code Mode is disabled for a server", () => { + let tools = [tool("alpha")] + return Effect.gen(function* () { + const service = yield* McpInstructions.Service + const initialized = yield* service.load(selection()).pipe(Effect.flatMap(readInitial)) + + tools = [new MCP.Tool({ server: MCP.ServerName.make("alpha"), name: "search", codemode: false })] + const changed = yield* readUpdate(yield* service.load(selection()), initialized) + expect(changed.text).toBe( + [ + "The available MCP server instructions have changed. This list supersedes the previous one.", + "", + ' ', + " Alpha instructions", + " ", + "", + ].join("\n"), + ) + }).pipe( + Effect.provide( + layer( + () => [instructions("alpha", "Alpha instructions")], + () => tools, + ), + ), + ) + }) + it.effect("renders additions, changes, and removal", () => { let catalog = [instructions("alpha", "Alpha instructions")] const tools = [tool("alpha"), tool("beta")]