Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
80 changes: 72 additions & 8 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand All @@ -435,6 +435,7 @@ export namespace ProviderTransform {
},
]),
)
}

case "@ai-sdk/cerebras":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/cerebras
Expand All @@ -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"]
Expand All @@ -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(() => {
Expand Down Expand Up @@ -496,6 +498,7 @@ export namespace ProviderTransform {
},
]),
)
}

case "@ai-sdk/anthropic":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic
Expand Down Expand Up @@ -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(
Expand All @@ -629,6 +632,7 @@ export namespace ProviderTransform {
},
]),
)
}

case "@ai-sdk/perplexity":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/perplexity
Expand Down Expand Up @@ -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<string, unknown>): 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<string, unknown>)
: 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<string, unknown>)[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))
Expand All @@ -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
}
Expand Down
52 changes: 51 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -744,6 +745,15 @@ export namespace SessionPrompt {
}) {
using _ = log.time("resolveTools")
const tools: Record<string, AITool> = {}
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<string>(),
}))

const context = (args: any, options: ToolCallOptions): Tool.Context => ({
sessionID: input.session.id,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1254,6 +1303,7 @@ export namespace SessionPrompt {
source: part.source,
},
]
}
}
}

Expand Down
111 changes: 111 additions & 0 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading
Loading