feat(core): allow MCP Code Mode opt-out (#37681)

Co-authored-by: Dax Raad <d@ironbay.co>
This commit is contained in:
opencode-agent[bot] 2026-07-18 17:17:56 -04:00 committed by GitHub
commit fe9b051d1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 57 additions and 9 deletions

View file

@ -23,6 +23,9 @@ export class Local extends Schema.Class<Local>("ConfigV2.MCP.Local")({
}),
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Expose this server's tools through Code Mode. Defaults to true.",
}),
timeout: Timeout.pipe(Schema.optional),
}) {}
@ -40,6 +43,9 @@ export class Remote extends Schema.Class<Remote>("ConfigV2.MCP.Remote")({
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
oauth: Schema.Union([OAuth, Schema.Literal(false)]).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Expose this server's tools through Code Mode. Defaults to true.",
}),
timeout: Timeout.pipe(Schema.optional),
}) {}

View file

@ -42,6 +42,7 @@ export class ServerInstructions extends Schema.Class<ServerInstructions>("MCP.Se
export class Tool extends Schema.Class<Tool>("MCP.Tool")({
server: ServerName,
name: Schema.String,
codemode: Schema.Boolean.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
inputSchema: Schema.Unknown.pipe(Schema.optional),
outputSchema: Schema.Unknown.pipe(Schema.optional),
@ -362,10 +363,11 @@ export const layer = Layer.effect(
}),
} satisfies MCPClient.ElicitationHandler
const toTool = (server: ServerName, def: MCPClient.ToolDefinition) =>
const toTool = (server: ServerName, entry: ServerEntry, def: MCPClient.ToolDefinition) =>
new Tool({
server,
name: def.name,
codemode: entry.config.codemode,
description: def.description,
inputSchema: def.inputSchema,
outputSchema: def.outputSchema,
@ -407,7 +409,7 @@ export const layer = Layer.effect(
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
connection.tools().pipe(
Effect.map((defs) => {
entry.tools = defs.map((def) => toTool(name, def))
entry.tools = defs.map((def) => toTool(name, entry, def))
}),
)
@ -498,7 +500,7 @@ export const layer = Layer.effect(
)
if (Exit.isSuccess(result)) {
entry.client = result.value.connection
entry.tools = result.value.tools.map((def) => toTool(name, def))
entry.tools = result.value.tools.map((def) => toTool(name, entry, def))
entry.prompts = []
entry.status = { status: "connected" }
watch(name, entry, result.value.connection)

View file

@ -16,8 +16,7 @@ import { ToolRegistry } from "./registry"
* Registry namespace and permission action names for MCP tools.
*/
export const namespace = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
export const name = (server: string, tool: string) =>
`${namespace(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
export const name = (server: string, tool: string) => `${namespace(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
@ -33,11 +32,11 @@ export const layer = Layer.effectDiscard(
// registry never has a gap where MCP tools disappear mid-swap.
const reconcile = lock.withPermit(
Effect.gen(function* () {
const groups = new Map<string, Record<string, Tool.AnyTool>>()
const groups = new Map<string, { tools: Record<string, Tool.AnyTool>; codemode: boolean }>()
for (const tool of yield* mcp.tools()) {
const group = groups.get(tool.server) ?? {}
const group = groups.get(tool.server) ?? { tools: {}, codemode: tool.codemode !== false }
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
group[tool.name] = Tool.withPermission(
group.tools[tool.name] = Tool.withPermission(
Tool.make({
description: tool.description ?? "",
jsonSchema: {
@ -108,7 +107,7 @@ export const layer = Layer.effectDiscard(
const next = yield* Scope.fork(scope)
yield* Effect.forEach(
groups,
([server, record]) => tools.register(record, { namespace: namespace(server) }),
([server, group]) => tools.register(group.tools, { namespace: namespace(server), codemode: group.codemode }),
{
discard: true,
},

View file

@ -655,6 +655,7 @@ describe("Config", () => {
command: ["node", "./mcp/server.js"],
environment: { API_KEY: "secret" },
disabled: false,
codemode: false,
timeout: { catalog: 10000 },
},
remote: {
@ -663,6 +664,7 @@ describe("Config", () => {
headers: { Authorization: "Bearer token" },
oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
disabled: true,
codemode: false,
timeout: { startup: 15000 },
},
},
@ -740,6 +742,7 @@ describe("Config", () => {
command: ["node", "./mcp/server.js"],
environment: { API_KEY: "secret" },
disabled: false,
codemode: false,
timeout: { catalog: 10000 },
},
remote: {
@ -748,6 +751,7 @@ describe("Config", () => {
headers: { Authorization: "Bearer token" },
oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
disabled: true,
codemode: false,
timeout: { startup: 15000 },
},
},

View file

@ -232,6 +232,13 @@ const mcp = Layer.mock(MCP.Service, {
required: ["ok"],
},
}),
new MCP.Tool({
server: MCP.ServerName.make("direct"),
name: "lookup",
codemode: false,
description: "Lookup",
inputSchema: { type: "object", properties: {} },
}),
]),
callTool: (input) =>
Effect.sync(() => {
@ -766,6 +773,18 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
}),
)
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "direct_lookup")
const definitions = yield* toolDefinitions(registry)
const execute = definitions.find((tool) => tool.name === "execute")
expect(definitions.some((tool) => tool.name === "direct_lookup")).toBe(true)
expect(execute?.description).not.toContain("tools.direct.lookup")
}),
)
it.effect("waits for permission before calling an MCP tool", () =>
Effect.gen(function* () {
calls = 0

View file

@ -75,6 +75,7 @@ A local server is a command that OpenCode starts using the MCP stdio transport.
| `cwd` | No | Process working directory. Relative paths resolve from the workspace directory; the workspace is the default. |
| `environment` | No | String environment variables added to the inherited OpenCode process environment. |
| `disabled` | No | Set to `true` to prevent the server from connecting. Defaults to `false`. |
| `codemode` | No | Set to `false` to expose the server's tools directly to the model instead of through Code Mode. Defaults to `true`. |
| `timeout` | No | Per-server timeout overrides. |
Use `{env:NAME}` to substitute an environment variable while loading config. Shell expressions such as `$NAME` are not expanded in JSON strings.
@ -108,6 +109,7 @@ A remote server uses the MCP Streamable HTTP transport. Its `url` must be a vali
| `headers` | No | String HTTP headers sent to the MCP endpoint. |
| `oauth` | No | OAuth client settings, or `false` to disable OAuth support. |
| `disabled` | No | Set to `true` to prevent the server from connecting. Defaults to `false`. |
| `codemode` | No | Set to `false` to expose the server's tools directly to the model instead of through Code Mode. Defaults to `true`. |
| `timeout` | No | Per-server timeout overrides. |
Use `oauth: false` for a server that exclusively uses an API key or another header-based credential.
@ -223,6 +225,22 @@ OpenCode combines the server name and MCP tool name as `<server>_<tool>`. Charac
Choose short server names that remain unique after normalization. Under the default Code Mode, MCP tools are grouped by the normalized server name.
Set `codemode` to `false` on a server when its tools should remain on the provider's native tool list:
```jsonc
{
"mcp": {
"servers": {
"context7": {
"type": "remote",
"url": "https://mcp.context7.com/mcp",
"codemode": false
}
}
}
}
```
Use permission actions to hide or deny a server's tools without stopping its connection:
```jsonc