fix(opencode): validate deferred MCP calls

This commit is contained in:
Aiden Cline 2026-06-24 23:23:16 -05:00
commit c6c4cd7dca
3 changed files with 53 additions and 3 deletions

View file

@ -1,19 +1,24 @@
import { jsonSchema, tool, type JSONSchema7, type Tool, type ToolExecutionOptions } from "ai"
import fuzzysort from "fuzzysort"
import { Token } from "@opencode-ai/core/util/token"
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv"
import type { JsonSchemaType, JsonSchemaValidator } from "@modelcontextprotocol/sdk/validation"
// Match Hermes defaults. OpenClaw independently uses the same maximum.
const DEFAULT_SEARCH_LIMIT = 5
const MAX_SEARCH_LIMIT = 20
const MAX_SEARCH_DESCRIPTION = 400
const SEARCH_THRESHOLD_TOKENS = 15_000
const CONTROL_NAMES = ["mcp_search", "mcp_describe", "mcp_call"]
const controls = new WeakSet<Tool>()
const validator = new AjvJsonSchemaValidator()
type Entry = {
id: string
description: string
parameters: string
schema: JSONSchema7
validate: JsonSchemaValidator<Record<string, unknown>>
tool: Tool
}
@ -29,6 +34,10 @@ export function isControl(item: Tool) {
return controls.has(item)
}
export function collides(tools: Record<string, Tool>) {
return CONTROL_NAMES.some((name) => tools[name])
}
export function create(input: {
tools: Record<string, Tool>
schemas: Record<string, JSONSchema7>
@ -46,6 +55,7 @@ export function create(input: {
description: item.description ?? "",
parameters: Object.keys(schema.properties ?? {}).join(" "),
schema,
validate: validator.getValidator<Record<string, unknown>>(schema as JsonSchemaType),
tool: item,
},
] as const
@ -139,7 +149,9 @@ export function create(input: {
async execute(args: { id: string; args?: Record<string, unknown> }, options: ToolExecutionOptions) {
const entry = resolve(entries, args.id)
if (!entry.tool.execute) throw new Error(`MCP tool "${entry.id}" is not executable`)
return entry.tool.execute(args.args ?? {}, options)
const result = entry.validate(args.args ?? {})
if (!result.valid) throw new Error(`Invalid arguments for MCP tool "${entry.id}": ${result.errorMessage}`)
return entry.tool.execute(result.data, options)
},
}),
}

View file

@ -502,6 +502,10 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
Object.assign(tools, mcpTools)
return tools
}
if (McpToolSearch.collides(tools)) {
Object.assign(tools, mcpTools)
return tools
}
const schemas = Object.fromEntries(Object.keys(searchable).map((key) => [key, mcpSchemas[key]]))
const controls = McpToolSearch.create({
@ -509,8 +513,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
schemas,
transformSchema: (schema) => ProviderTransform.schema(input.model, schema),
})
const collision = Object.keys(controls).find((key) => tools[key])
if (collision) throw new Error(`Tool name reserved for MCP tool search: ${collision}`)
Object.assign(tools, controls)
return tools
})

View file

@ -60,6 +60,11 @@ describe("MCP tool search", () => {
expect(McpToolSearch.shouldUse(tools, schemas)).toBe(true)
})
test("detects control-name collisions", () => {
expect(McpToolSearch.collides({ mcp_call: target("mcp_call", "Plugin tool") })).toBe(true)
expect(McpToolSearch.collides({ other: target("other", "Plugin tool") })).toBe(false)
})
test("exposes only the three stable control tools", async () => {
expect(Object.keys(await catalog())).toEqual(["mcp_search", "mcp_describe", "mcp_call"])
})
@ -120,6 +125,37 @@ describe("MCP tool search", () => {
expect(result.output).toBe('{"title":"Cache bug"}')
})
test("validates hidden target arguments before execution", async () => {
let calls = 0
const tools = McpToolSearch.create({
tools: {
create_issue: tool({
inputSchema: jsonSchema({}),
async execute() {
calls++
return { output: "called" }
},
}),
},
schemas: {
create_issue: {
type: "object",
properties: { title: { type: "string" } },
required: ["title"],
additionalProperties: false,
},
},
transformSchema: (schema) => schema,
})
expect(
tools.mcp_call!.execute?.(
{ id: "create_issue", args: {} },
{ toolCallId: "call", messages: [], abortSignal: new AbortController().signal },
),
).rejects.toThrow('Invalid arguments for MCP tool "create_issue"')
expect(calls).toBe(0)
})
test("suggests but does not execute inexact tool names", async () => {
const tools = await McpToolSearch.create({
tools: {