fix(opencode): validate deferred MCP calls
This commit is contained in:
parent
bc189de080
commit
c6c4cd7dca
3 changed files with 53 additions and 3 deletions
|
|
@ -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)
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue