diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index dfdcb0343ef..b3215596d5b 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -537,6 +537,16 @@ export namespace Config { .positive() .optional() .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), + includeTools: z + .string() + .array() + .optional() + .describe("Allowlist: only expose these tool names from this MCP server to the model"), + excludeTools: z + .string() + .array() + .optional() + .describe("Denylist: exclude these tool names from this MCP server (takes precedence over includeTools)"), }) .strict() .meta({ @@ -576,6 +586,16 @@ export namespace Config { .positive() .optional() .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), + includeTools: z + .string() + .array() + .optional() + .describe("Allowlist: only expose these tool names from this MCP server to the model"), + excludeTools: z + .string() + .array() + .optional() + .describe("Denylist: exclude these tool names from this MCP server (takes precedence over includeTools)"), }) .strict() .meta({ diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 759dab440d4..289d59c4bfb 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -410,7 +410,7 @@ export namespace ProviderTransform { } return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) - case "@ai-sdk/github-copilot": + case "@ai-sdk/github-copilot": { if (model.id.includes("gemini")) { // currently github copilot only returns thinking return {} @@ -435,6 +435,7 @@ export namespace ProviderTransform { }, ]), ) + } case "@ai-sdk/cerebras": // https://v5.ai-sdk.dev/providers/ai-sdk-providers/cerebras @@ -449,7 +450,7 @@ export namespace ProviderTransform { case "@ai-sdk/openai-compatible": return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) - case "@ai-sdk/azure": + case "@ai-sdk/azure": { // https://v5.ai-sdk.dev/providers/ai-sdk-providers/azure if (id === "o1-mini") return {} const azureEfforts = ["low", "medium", "high"] @@ -466,7 +467,8 @@ export namespace ProviderTransform { }, ]), ) - case "@ai-sdk/openai": + } + case "@ai-sdk/openai": { // https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai if (id === "gpt-5-pro") return {} const openaiEfforts = iife(() => { @@ -496,6 +498,7 @@ export namespace ProviderTransform { }, ]), ) + } case "@ai-sdk/anthropic": // https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic @@ -617,7 +620,7 @@ export namespace ProviderTransform { // https://v5.ai-sdk.dev/providers/ai-sdk-providers/cohere return {} - case "@ai-sdk/groq": + case "@ai-sdk/groq": { // https://v5.ai-sdk.dev/providers/ai-sdk-providers/groq const groqEffort = ["none", ...WIDELY_SUPPORTED_EFFORTS] return Object.fromEntries( @@ -629,6 +632,7 @@ export namespace ProviderTransform { }, ]), ) + } case "@ai-sdk/perplexity": // https://v5.ai-sdk.dev/providers/ai-sdk-providers/perplexity @@ -876,17 +880,77 @@ export namespace ProviderTransform { // Convert integer enums to string enums for Google/Gemini if (model.providerID === "google" || model.api.id.includes("gemini")) { - const sanitizeGemini = (obj: any): any => { + const sanitizeGemini = (obj: any, defs?: Record): any => { if (obj === null || typeof obj !== "object") { return obj } if (Array.isArray(obj)) { - return obj.map(sanitizeGemini) + return obj.map((item) => sanitizeGemini(item, defs)) + } + + const localDefs = + obj.$defs && typeof obj.$defs === "object" && !Array.isArray(obj.$defs) + ? (obj.$defs as Record) + : defs + + const resolveRef = (ref: string): unknown => { + if (!localDefs || !ref.startsWith("#/$defs/")) return undefined + const path = ref.slice("#/$defs/".length).split("/").filter(Boolean) + let current: unknown = localDefs + for (const segment of path) { + if (current === null || typeof current !== "object" || Array.isArray(current)) return undefined + current = (current as Record)[segment] + } + return current } - const result: any = {} + const resolvedRef = typeof obj.$ref === "string" ? resolveRef(obj.$ref) : undefined + const result: any = + resolvedRef && typeof resolvedRef === "object" ? sanitizeGemini(resolvedRef, localDefs) : {} + + const stripKeys = new Set([ + "additionalProperties", + "$schema", + "$defs", + "minItems", + "maxItems", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "minLength", + "maxLength", + "minProperties", + "maxProperties", + "patternProperties", + "propertyNames", + "not", + "if", + "then", + "else", + "const", + "contains", + "unevaluatedProperties", + ]) + for (const [key, value] of Object.entries(obj)) { + if (stripKeys.has(key) || key === "$ref") { + continue + } + + if (key === "type" && Array.isArray(value)) { + const types = value.filter((item): item is string => typeof item === "string") + const nonNullTypes = types.filter((item) => item !== "null") + if (types.includes("null") && nonNullTypes.length === 1) { + result.type = nonNullTypes[0] + result.nullable = true + } else { + result.type = value + } + continue + } + if (key === "enum" && Array.isArray(value)) { // Convert all enum values to strings result[key] = value.map((v) => String(v)) @@ -895,7 +959,7 @@ export namespace ProviderTransform { result.type = "string" } } else if (typeof value === "object" && value !== null) { - result[key] = sanitizeGemini(value) + result[key] = sanitizeGemini(value, localDefs) } else { result[key] = value } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index d1f4072586e..6fe6c57b407 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -32,6 +32,7 @@ import { ulid } from "ulid" import { spawn } from "child_process" import { Command } from "../command" import { $, fileURLToPath, pathToFileURL } from "bun" +import { Config } from "../config/config" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/util/error" @@ -744,6 +745,15 @@ export namespace SessionPrompt { }) { using _ = log.time("resolveTools") const tools: Record = {} + const cfg = await Config.get() + const mcpConfig = cfg.mcp ?? {} + const mcpServers = Object.entries(mcpConfig).map(([serverName, serverConfig]) => ({ + serverName, + sanitizedServerName: serverName.replace(/[^a-zA-Z0-9_-]/g, "_"), + includeTools: "includeTools" in serverConfig ? serverConfig.includeTools : undefined, + excludeTools: "excludeTools" in serverConfig ? serverConfig.excludeTools : undefined, + seenTools: new Set(), + })) const context = (args: any, options: ToolCallOptions): Tool.Context => ({ sessionID: input.session.id, @@ -831,6 +841,33 @@ export namespace SessionPrompt { const execute = item.execute if (!execute) continue + const matchedServer = mcpServers + .filter((server) => key.startsWith(server.sanitizedServerName + "_")) + .sort((a, b) => b.sanitizedServerName.length - a.sanitizedServerName.length)[0] + + if (matchedServer) { + const toolName = key.slice(matchedServer.sanitizedServerName.length + 1) + matchedServer.seenTools.add(toolName) + + if (matchedServer.excludeTools?.includes(toolName)) { + log.warn("filtered MCP tool", { + serverName: matchedServer.serverName, + toolName, + reason: "excludeTools", + }) + continue + } + + if (matchedServer.includeTools && !matchedServer.includeTools.includes(toolName)) { + log.warn("filtered MCP tool", { + serverName: matchedServer.serverName, + toolName, + reason: "not_in_includeTools", + }) + continue + } + } + const transformed = ProviderTransform.schema(input.model, asSchema(item.inputSchema).jsonSchema) item.inputSchema = jsonSchema(transformed) // Wrap execute to add plugin hooks and format output @@ -915,6 +952,18 @@ export namespace SessionPrompt { tools[key] = item } + for (const server of mcpServers) { + if (!server.includeTools) continue + for (const toolName of server.includeTools) { + if (server.seenTools.has(toolName)) continue + log.warn("includeTools references unknown MCP tool", { + serverName: server.serverName, + toolName, + reason: "missing_from_server", + }) + } + } + return tools } @@ -1077,7 +1126,7 @@ export namespace SessionPrompt { ] } break - case "file:": + case "file:": { log.info("file", { mime: part.mime }) // have to normalize, symbol search returns absolute paths // Decode the pathname since URL constructor doesn't automatically decode it @@ -1254,6 +1303,7 @@ export namespace SessionPrompt { source: part.source, }, ] + } } } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 91b87f6498c..4af122279fa 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1602,6 +1602,117 @@ describe("deduplicatePlugins", () => { }) }) +describe("MCP includeTools and excludeTools config", () => { + test("parses MCP local config with includeTools", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + mcp: { + myserver: { + type: "local", + command: ["npx", "my-mcp"], + includeTools: ["tool_a", "tool_b"], + }, + }, + }) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.mcp?.myserver).toEqual({ + type: "local", + command: ["npx", "my-mcp"], + includeTools: ["tool_a", "tool_b"], + }) + }, + }) + }) + + test("parses MCP remote config with excludeTools", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + mcp: { + remote: { + type: "remote", + url: "https://remote.example.com/mcp", + excludeTools: ["tool_x"], + }, + }, + }) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.mcp?.remote).toEqual({ + type: "remote", + url: "https://remote.example.com/mcp", + excludeTools: ["tool_x"], + }) + }, + }) + }) + + test("parses MCP config with both includeTools and excludeTools", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + mcp: { + myserver: { + type: "local", + command: ["npx", "my-mcp"], + includeTools: ["tool_a", "tool_b"], + excludeTools: ["tool_x"], + }, + }, + }) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.mcp?.myserver).toEqual({ + type: "local", + command: ["npx", "my-mcp"], + includeTools: ["tool_a", "tool_b"], + excludeTools: ["tool_x"], + }) + }, + }) + }) + + test("MCP strict schema still rejects unknown fields", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + mcp: { + myserver: { + type: "local", + command: ["npx", "my-mcp"], + unknownField: "should fail", + }, + }, + }) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(Config.get()).rejects.toThrow() + }, + }) + }) +}) + describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { test("skips project config files when flag is set", async () => { const originalEnv = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 3494cb56fdd..bff1520f236 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -620,6 +620,166 @@ describe("ProviderTransform.schema - gemini non-object properties removal", () = }) }) +describe("ProviderTransform.schema - gemini keyword sanitization", () => { + const geminiModel = { + providerID: "google", + api: { + id: "gemini-3-pro", + }, + } as any + + test("strips additionalProperties recursively", () => { + const schema = { + type: "object", + additionalProperties: false, + properties: { + nested: { + type: "object", + additionalProperties: true, + properties: { + values: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + id: { type: "string" }, + }, + }, + }, + }, + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.additionalProperties).toBeUndefined() + expect(result.properties.nested.additionalProperties).toBeUndefined() + expect(result.properties.nested.properties.values.items.additionalProperties).toBeUndefined() + }) + + test("converts nullable type unions to nullable schemas", () => { + const schema = { + type: "object", + properties: { + s: { type: ["null", "string"] }, + n: { type: ["null", "number"] }, + i: { type: ["null", "integer"] }, + b: { type: ["null", "boolean"] }, + a: { type: ["null", "array"], items: { type: "string" } }, + o: { type: ["null", "object"], properties: { name: { type: "string" } } }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.s).toMatchObject({ type: "string", nullable: true }) + expect(result.properties.n).toMatchObject({ type: "number", nullable: true }) + expect(result.properties.i).toMatchObject({ type: "integer", nullable: true }) + expect(result.properties.b).toMatchObject({ type: "boolean", nullable: true }) + expect(result.properties.a).toMatchObject({ type: "array", nullable: true }) + expect(result.properties.o).toMatchObject({ type: "object", nullable: true }) + }) + + test("strips schema/range and advanced keywords", () => { + const schema = JSON.parse(` + { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "minProperties": 1, + "maxProperties": 10, + "patternProperties": { + "^x-": { "type": "string" } + }, + "propertyNames": { "pattern": "^[a-z]+$" }, + "not": { "type": "null" }, + "if": { "required": ["count"] }, + "then": { "properties": { "count": { "minimum": 1 } } }, + "else": { "properties": { "count": { "maximum": 0 } } }, + "const": {}, + "contains": { "type": "string" }, + "unevaluatedProperties": false, + "properties": { + "list": { + "type": "array", + "minItems": 1, + "maxItems": 5, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 20 + } + }, + "count": { + "type": "number", + "minimum": 0, + "maximum": 10, + "exclusiveMinimum": -1, + "exclusiveMaximum": 11 + } + } + } + `) as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.$schema).toBeUndefined() + expect(result.minProperties).toBeUndefined() + expect(result.maxProperties).toBeUndefined() + expect(result.patternProperties).toBeUndefined() + expect(result.propertyNames).toBeUndefined() + expect(result.not).toBeUndefined() + expect(result.if).toBeUndefined() + expect(result.then).toBeUndefined() + expect(result.else).toBeUndefined() + expect(result.const).toBeUndefined() + expect(result.contains).toBeUndefined() + expect(result.unevaluatedProperties).toBeUndefined() + expect(result.properties.list.minItems).toBeUndefined() + expect(result.properties.list.maxItems).toBeUndefined() + expect(result.properties.list.items.minLength).toBeUndefined() + expect(result.properties.list.items.maxLength).toBeUndefined() + expect(result.properties.count.minimum).toBeUndefined() + expect(result.properties.count.maximum).toBeUndefined() + expect(result.properties.count.exclusiveMinimum).toBeUndefined() + expect(result.properties.count.exclusiveMaximum).toBeUndefined() + }) + + test("strips $defs and resolves local $ref entries", () => { + const schema = { + type: "object", + $defs: { + Item: { + type: "object", + additionalProperties: false, + properties: { + id: { type: "string" }, + }, + required: ["id"], + }, + }, + properties: { + resolved: { + $ref: "#/$defs/Item", + }, + unresolved: { + $ref: "#/components/schemas/Unknown", + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.$defs).toBeUndefined() + expect(result.properties.resolved.$ref).toBeUndefined() + expect(result.properties.resolved.type).toBe("object") + expect(result.properties.resolved.properties.id.type).toBe("string") + expect(result.properties.resolved.additionalProperties).toBeUndefined() + expect(result.properties.unresolved.$ref).toBeUndefined() + }) +}) + describe("ProviderTransform.message - DeepSeek reasoning content", () => { test("DeepSeek with tool calls includes reasoning_content in providerOptions", () => { const msgs = [ diff --git a/packages/opencode/test/session/prompt-tool-filter.test.ts b/packages/opencode/test/session/prompt-tool-filter.test.ts new file mode 100644 index 00000000000..bfcbbe0dcb8 --- /dev/null +++ b/packages/opencode/test/session/prompt-tool-filter.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { jsonSchema } from "ai" +import { Config } from "../../src/config/config" +import { MCP } from "../../src/mcp" +import { SessionPrompt } from "../../src/session/prompt" +import { ToolRegistry } from "../../src/tool/registry" + +type ResolveToolsInput = Parameters[0] +type McpToolsMap = Awaited> +type ConfigInfo = Awaited> + +const baseInput = (): ResolveToolsInput => + ({ + agent: { + name: "test-agent", + permission: [], + }, + model: { + providerID: "openai", + api: { + id: "gpt-5.1", + }, + }, + session: { + id: "session_test", + permission: [], + }, + processor: { + message: { id: "message_test" }, + partFromToolCall: () => undefined, + }, + bypassAgentCheck: false, + messages: [], + }) as unknown as ResolveToolsInput + +const createMcpTool = () => + ({ + description: "mock tool", + inputSchema: jsonSchema({ type: "object", properties: {} }), + execute: async () => ({ + content: [], + }), + }) as McpToolsMap[string] + +const createConfig = (mcp: ConfigInfo["mcp"]): ConfigInfo => ({ + mcp, +}) as ConfigInfo + +describe("session.prompt MCP tool filtering", () => { + let toolsSpy: ReturnType + let configSpy: ReturnType + let registrySpy: ReturnType + + beforeEach(() => { + toolsSpy = spyOn(MCP, "tools").mockResolvedValue({}) + configSpy = spyOn(Config, "get").mockResolvedValue(createConfig({})) + registrySpy = spyOn(ToolRegistry, "tools").mockResolvedValue([]) + }) + + afterEach(() => { + toolsSpy.mockRestore() + configSpy.mockRestore() + registrySpy.mockRestore() + }) + + test("includeTools limits tool set for one server", async () => { + toolsSpy.mockResolvedValue({ + "miro-community_miro_list_boards": createMcpTool(), + "miro-community_miro_create_board": createMcpTool(), + }) + configSpy.mockResolvedValue( + createConfig({ + "miro-community": { + type: "remote", + url: "https://example.com/mcp", + includeTools: ["miro_list_boards"], + }, + }), + ) + + const resolved = await SessionPrompt.resolveTools(baseInput()) + + expect(Object.keys(resolved)).toEqual(["miro-community_miro_list_boards"]) + }) + + test("excludeTools removes specific tools and keeps others", async () => { + toolsSpy.mockResolvedValue({ + "miro-community_miro_list_boards": createMcpTool(), + "miro-community_miro_create_board": createMcpTool(), + }) + configSpy.mockResolvedValue( + createConfig({ + "miro-community": { + type: "remote", + url: "https://example.com/mcp", + excludeTools: ["miro_create_board"], + }, + }), + ) + + const resolved = await SessionPrompt.resolveTools(baseInput()) + + expect(Object.keys(resolved)).toEqual(["miro-community_miro_list_boards"]) + }) + + test("excludeTools takes precedence over includeTools", async () => { + toolsSpy.mockResolvedValue({ + "miro-community_miro_list_boards": createMcpTool(), + "miro-community_miro_create_board": createMcpTool(), + }) + configSpy.mockResolvedValue( + createConfig({ + "miro-community": { + type: "remote", + url: "https://example.com/mcp", + includeTools: ["miro_list_boards", "miro_create_board"], + excludeTools: ["miro_list_boards"], + }, + }), + ) + + const resolved = await SessionPrompt.resolveTools(baseInput()) + + expect(Object.keys(resolved)).toEqual(["miro-community_miro_create_board"]) + }) + + test("filters apply only to configured server", async () => { + toolsSpy.mockResolvedValue({ + "miro-community_miro_list_boards": createMcpTool(), + "miro-community_miro_create_board": createMcpTool(), + weather_server_get_forecast: createMcpTool(), + }) + configSpy.mockResolvedValue( + createConfig({ + "miro-community": { + type: "remote", + url: "https://example.com/mcp", + includeTools: ["miro_list_boards"], + }, + }), + ) + + const resolved = await SessionPrompt.resolveTools(baseInput()) + + expect(Object.keys(resolved).sort()).toEqual([ + "miro-community_miro_list_boards", + "weather_server_get_forecast", + ]) + }) + + test("without includeTools/excludeTools all MCP tools pass through", async () => { + toolsSpy.mockResolvedValue({ + "miro-community_miro_list_boards": createMcpTool(), + "miro-community_miro_create_board": createMcpTool(), + }) + configSpy.mockResolvedValue( + createConfig({ + "miro-community": { + type: "remote", + url: "https://example.com/mcp", + }, + }), + ) + + const resolved = await SessionPrompt.resolveTools(baseInput()) + + expect(Object.keys(resolved).sort()).toEqual([ + "miro-community_miro_create_board", + "miro-community_miro_list_boards", + ]) + }) + + test("empty includeTools excludes all tools from that server", async () => { + toolsSpy.mockResolvedValue({ + "miro-community_miro_list_boards": createMcpTool(), + "miro-community_miro_create_board": createMcpTool(), + }) + configSpy.mockResolvedValue( + createConfig({ + "miro-community": { + type: "remote", + url: "https://example.com/mcp", + includeTools: [], + }, + }), + ) + + const resolved = await SessionPrompt.resolveTools(baseInput()) + + expect(Object.keys(resolved)).toEqual([]) + }) +})