diff --git a/packages/opencode/src/session/mcp-tools.ts b/packages/opencode/src/session/mcp-tools.ts index ba18788090..cd04f50575 100644 --- a/packages/opencode/src/session/mcp-tools.ts +++ b/packages/opencode/src/session/mcp-tools.ts @@ -238,7 +238,10 @@ export const resolve = Effect.fn("SessionMcpTools.resolve")(function* (input: In Object.keys(mcpTools), Permission.merge(input.agent.permission, input.session.permission ?? []), ) - const allowedMcpTools = Object.fromEntries(Object.entries(mcpTools).filter(([key]) => !mcpDisabled.has(key))) + const userTools = currentUserToolOverrides(input.messages) + const allowedMcpTools = Object.fromEntries( + Object.entries(mcpTools).filter(([key]) => userTools?.[key] !== false && !mcpDisabled.has(key)), + ) const deferredDescriptors = flags.experimentalToolSearch && Object.keys(allowedMcpTools).length > 0 ? yield* deferredToolDescriptors(allowedMcpTools) @@ -277,6 +280,7 @@ export const resolve = Effect.fn("SessionMcpTools.resolve")(function* (input: In export const systemPrompt = Effect.fn("SessionMcpTools.systemPrompt")(function* (input: { agent: Agent.Info session: Session.Info + messages: SessionV1.WithParts[] }) { const mcp = yield* MCP.Service const flags = yield* RuntimeFlags.Service @@ -287,7 +291,10 @@ export const systemPrompt = Effect.fn("SessionMcpTools.systemPrompt")(function* Object.keys(mcpTools), Permission.merge(input.agent.permission, input.session.permission ?? []), ) - const allowedTools = Object.fromEntries(Object.entries(mcpTools).filter(([key]) => !mcpDisabled.has(key))) + const userTools = currentUserToolOverrides(input.messages) + const allowedTools = Object.fromEntries( + Object.entries(mcpTools).filter(([key]) => userTools?.[key] !== false && !mcpDisabled.has(key)), + ) if (Object.keys(allowedTools).length === 0) return undefined const deferredDescriptors = yield* deferredToolDescriptors(allowedTools) @@ -755,6 +762,11 @@ function searchDeferredTools(descriptors: DeferredToolDescriptor[], query: strin .map((item) => item.descriptor) } +function currentUserToolOverrides(messages: SessionV1.WithParts[]) { + const user = messages.findLast((message) => message.info.role === "user") + return user?.info.role === "user" ? user.info.tools : undefined +} + function mcpToolContent(result: unknown): McpToolContent[] { if (!isRecord(result) || !Array.isArray(result.content)) return [] return result.content.flatMap((item): McpToolContent[] => { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 86f0495dba..116839323e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1260,7 +1260,7 @@ export const layer = Layer.effect( instruction.system().pipe(Effect.orDie), sys.mcp(agent, session.permission), tools.search_deferred_tools && tools.call_deferred_tool - ? SessionTools.deferredSystemPrompt({ agent, session }).pipe( + ? SessionTools.deferredSystemPrompt({ agent, session, messages: msgs }).pipe( Effect.provideService(MCP.Service, mcp), Effect.provideService(RuntimeFlags.Service, flags), ) diff --git a/packages/opencode/test/session/tools.test.ts b/packages/opencode/test/session/tools.test.ts index 6c2762ec2e..d0d03871d5 100644 --- a/packages/opencode/test/session/tools.test.ts +++ b/packages/opencode/test/session/tools.test.ts @@ -54,6 +54,14 @@ const assistant = { time: { created: 0 }, sessionID, } satisfies SessionV1.Assistant +const user = { + id: MessageID.make("msg_user"), + sessionID, + role: "user", + time: { created: 0 }, + agent: agent.name, + model: { providerID: ProviderV2.ID.make(model.providerID), modelID: ModelV2.ID.make(model.id) }, +} satisfies SessionV1.User const processor = { message: assistant, updateToolCall: () => Effect.succeed(undefined), @@ -124,14 +132,14 @@ const belowThresholdIt = makeIt({ queryDescription: "Natural language analytics query", }) -function resolveTools() { +function resolveTools(messages: SessionV1.WithParts[] = []) { return SessionTools.resolve({ agent, model, session, processor, bypassAgentCheck: false, - messages: [], + messages, promptOps, }) } @@ -172,13 +180,40 @@ describe("session.tools", () => { deferredIt.instance("lists deferred MCP servers in the system prompt", () => Effect.gen(function* () { - const prompt = yield* SessionTools.deferredSystemPrompt({ agent, session }) + const prompt = yield* SessionTools.deferredSystemPrompt({ agent, session, messages: [] }) expect(prompt).toContain("Deferred MCP servers available through `search_deferred_tools`:") expect(prompt).toContain("- posthog: 2 tools") }), ) + deferredIt.instance("does not expose per-message disabled MCP tools through deferred search", () => + Effect.gen(function* () { + const tools = yield* resolveTools([ + { + info: { ...user, tools: { posthog_feature_flags: false } }, + parts: [], + }, + ]) + + const search = tools.search_deferred_tools.execute + if (!search) throw new Error("missing search_deferred_tools executor") + const searchResult = yield* Effect.promise(() => + Promise.resolve(search({ query: "feature flags" }, toolExecutionOptions)), + ) + const parsed = JSON.parse(searchResult.output) as { tools: Array<{ tool_id: string }> } + expect(parsed.tools.map((item) => item.tool_id)).not.toContain("posthog_feature_flags") + + const call = tools.call_deferred_tool.execute + if (!call) throw new Error("missing call_deferred_tool executor") + yield* Effect.promise(async () => { + await expect( + Promise.resolve(call({ tool_id: "posthog_feature_flags", arguments: {} }, toolExecutionOptions)), + ).rejects.toThrow('Deferred tool "posthog_feature_flags" is not available') + }) + }), + ) + directIt.instance("keeps MCP tools direct when tool search is disabled", () => Effect.gen(function* () { const tools = yield* resolveTools()