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
co-authored by Dax Raad
parent 33f1b269e9
commit fe9b051d1a
6 changed files with 57 additions and 9 deletions
+6
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), environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
disabled: Schema.Boolean.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), 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), headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
oauth: Schema.Union([OAuth, Schema.Literal(false)]).pipe(Schema.optional), oauth: Schema.Union([OAuth, Schema.Literal(false)]).pipe(Schema.optional),
disabled: Schema.Boolean.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), timeout: Timeout.pipe(Schema.optional),
}) {} }) {}
+5 -3
View File
@@ -42,6 +42,7 @@ export class ServerInstructions extends Schema.Class<ServerInstructions>("MCP.Se
export class Tool extends Schema.Class<Tool>("MCP.Tool")({ export class Tool extends Schema.Class<Tool>("MCP.Tool")({
server: ServerName, server: ServerName,
name: Schema.String, name: Schema.String,
codemode: Schema.Boolean.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional), description: Schema.String.pipe(Schema.optional),
inputSchema: Schema.Unknown.pipe(Schema.optional), inputSchema: Schema.Unknown.pipe(Schema.optional),
outputSchema: Schema.Unknown.pipe(Schema.optional), outputSchema: Schema.Unknown.pipe(Schema.optional),
@@ -362,10 +363,11 @@ export const layer = Layer.effect(
}), }),
} satisfies MCPClient.ElicitationHandler } satisfies MCPClient.ElicitationHandler
const toTool = (server: ServerName, def: MCPClient.ToolDefinition) => const toTool = (server: ServerName, entry: ServerEntry, def: MCPClient.ToolDefinition) =>
new Tool({ new Tool({
server, server,
name: def.name, name: def.name,
codemode: entry.config.codemode,
description: def.description, description: def.description,
inputSchema: def.inputSchema, inputSchema: def.inputSchema,
outputSchema: def.outputSchema, outputSchema: def.outputSchema,
@@ -407,7 +409,7 @@ export const layer = Layer.effect(
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
connection.tools().pipe( connection.tools().pipe(
Effect.map((defs) => { 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)) { if (Exit.isSuccess(result)) {
entry.client = result.value.connection 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.prompts = []
entry.status = { status: "connected" } entry.status = { status: "connected" }
watch(name, entry, result.value.connection) watch(name, entry, result.value.connection)
+5 -6
View File
@@ -16,8 +16,7 @@ import { ToolRegistry } from "./registry"
* Registry namespace and permission action names for MCP tools. * Registry namespace and permission action names for MCP tools.
*/ */
export const namespace = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_") export const namespace = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
export const name = (server: string, tool: string) => export const name = (server: string, tool: string) => `${namespace(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
`${namespace(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
export const layer = Layer.effectDiscard( export const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
@@ -33,11 +32,11 @@ export const layer = Layer.effectDiscard(
// registry never has a gap where MCP tools disappear mid-swap. // registry never has a gap where MCP tools disappear mid-swap.
const reconcile = lock.withPermit( const reconcile = lock.withPermit(
Effect.gen(function* () { 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()) { 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 const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
group[tool.name] = Tool.withPermission( group.tools[tool.name] = Tool.withPermission(
Tool.make({ Tool.make({
description: tool.description ?? "", description: tool.description ?? "",
jsonSchema: { jsonSchema: {
@@ -108,7 +107,7 @@ export const layer = Layer.effectDiscard(
const next = yield* Scope.fork(scope) const next = yield* Scope.fork(scope)
yield* Effect.forEach( yield* Effect.forEach(
groups, groups,
([server, record]) => tools.register(record, { namespace: namespace(server) }), ([server, group]) => tools.register(group.tools, { namespace: namespace(server), codemode: group.codemode }),
{ {
discard: true, discard: true,
}, },
+4
View File
@@ -655,6 +655,7 @@ describe("Config", () => {
command: ["node", "./mcp/server.js"], command: ["node", "./mcp/server.js"],
environment: { API_KEY: "secret" }, environment: { API_KEY: "secret" },
disabled: false, disabled: false,
codemode: false,
timeout: { catalog: 10000 }, timeout: { catalog: 10000 },
}, },
remote: { remote: {
@@ -663,6 +664,7 @@ describe("Config", () => {
headers: { Authorization: "Bearer token" }, headers: { Authorization: "Bearer token" },
oauth: { client_id: "client", scope: "read write", callback_port: 19876 }, oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
disabled: true, disabled: true,
codemode: false,
timeout: { startup: 15000 }, timeout: { startup: 15000 },
}, },
}, },
@@ -740,6 +742,7 @@ describe("Config", () => {
command: ["node", "./mcp/server.js"], command: ["node", "./mcp/server.js"],
environment: { API_KEY: "secret" }, environment: { API_KEY: "secret" },
disabled: false, disabled: false,
codemode: false,
timeout: { catalog: 10000 }, timeout: { catalog: 10000 },
}, },
remote: { remote: {
@@ -748,6 +751,7 @@ describe("Config", () => {
headers: { Authorization: "Bearer token" }, headers: { Authorization: "Bearer token" },
oauth: { client_id: "client", scope: "read write", callback_port: 19876 }, oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
disabled: true, disabled: true,
codemode: false,
timeout: { startup: 15000 }, timeout: { startup: 15000 },
}, },
}, },
+19
View File
@@ -232,6 +232,13 @@ const mcp = Layer.mock(MCP.Service, {
required: ["ok"], required: ["ok"],
}, },
}), }),
new MCP.Tool({
server: MCP.ServerName.make("direct"),
name: "lookup",
codemode: false,
description: "Lookup",
inputSchema: { type: "object", properties: {} },
}),
]), ]),
callTool: (input) => callTool: (input) =>
Effect.sync(() => { 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", () => it.effect("waits for permission before calling an MCP tool", () =>
Effect.gen(function* () { Effect.gen(function* () {
calls = 0 calls = 0
+18
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. | | `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. | | `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`. | | `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. | | `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. 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. | | `headers` | No | String HTTP headers sent to the MCP endpoint. |
| `oauth` | No | OAuth client settings, or `false` to disable OAuth support. | | `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`. | | `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. | | `timeout` | No | Per-server timeout overrides. |
Use `oauth: false` for a server that exclusively uses an API key or another header-based credential. 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. 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: Use permission actions to hide or deny a server's tools without stopping its connection:
```jsonc ```jsonc