Compare commits
3 commits
dev
...
mcp-search
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6c4cd7dca | ||
|
|
bc189de080 | ||
|
|
94b7d450c5 |
6 changed files with 485 additions and 9 deletions
|
|
@ -48,7 +48,6 @@ export const Flag = {
|
||||||
|
|
||||||
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
|
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
|
||||||
OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||||
|
|
||||||
// Evaluated at access time (not module load) because tests, the CLI, and
|
// Evaluated at access time (not module load) because tests, the CLI, and
|
||||||
// external tooling set these env vars at runtime.
|
// external tooling set these env vars at runtime.
|
||||||
get OPENCODE_DISABLE_PROJECT_CONFIG() {
|
get OPENCODE_DISABLE_PROJECT_CONFIG() {
|
||||||
|
|
|
||||||
177
packages/opencode/src/mcp/tool-search.ts
Normal file
177
packages/opencode/src/mcp/tool-search.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldUse(tools: Record<string, Tool>, schemas: Record<string, JSONSchema7>) {
|
||||||
|
const catalog = Object.entries(tools)
|
||||||
|
.toSorted(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([name, item]) => JSON.stringify({ name, description: item.description, inputSchema: schemas[name] }))
|
||||||
|
.join("\n")
|
||||||
|
return Token.estimate(catalog) > SEARCH_THRESHOLD_TOKENS
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
transformSchema: (schema: JSONSchema7) => JSONSchema7
|
||||||
|
}): Record<string, Tool> {
|
||||||
|
const entries = new Map<string, Entry>(
|
||||||
|
Object.entries(input.tools)
|
||||||
|
.toSorted(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([id, item]) => {
|
||||||
|
const schema = input.schemas[id]
|
||||||
|
return [
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
description: item.description ?? "",
|
||||||
|
parameters: Object.keys(schema.properties ?? {}).join(" "),
|
||||||
|
schema,
|
||||||
|
validate: validator.getValidator<Record<string, unknown>>(schema as JsonSchemaType),
|
||||||
|
tool: item,
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (entries.size === 0) return {}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
mcp_search: tool({
|
||||||
|
description:
|
||||||
|
"Search connected MCP tools by capability. Returns matching tool IDs and short descriptions. Use mcp_describe to inspect a tool before calling it.",
|
||||||
|
inputSchema: jsonSchema<{ query: string; limit?: number }>(
|
||||||
|
input.transformSchema({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Capability, tool name, or keywords to search for." },
|
||||||
|
limit: {
|
||||||
|
type: "integer",
|
||||||
|
minimum: 1,
|
||||||
|
maximum: MAX_SEARCH_LIMIT,
|
||||||
|
description: `Maximum results to return (default ${DEFAULT_SEARCH_LIMIT}, maximum ${MAX_SEARCH_LIMIT}).`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["query"],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async execute(args: { query: string; limit?: number }) {
|
||||||
|
const query = args.query.trim()
|
||||||
|
if (!query) throw new Error("query must be a non-empty string")
|
||||||
|
const limit = Math.min(MAX_SEARCH_LIMIT, Math.max(1, Math.trunc(args.limit ?? DEFAULT_SEARCH_LIMIT)))
|
||||||
|
const matches = search([...entries.values()], query, limit)
|
||||||
|
return {
|
||||||
|
title: `MCP tools matching ${args.query}`,
|
||||||
|
metadata: { query: args.query, count: matches.length },
|
||||||
|
output: JSON.stringify(
|
||||||
|
{
|
||||||
|
query,
|
||||||
|
totalAvailable: entries.size,
|
||||||
|
tools: matches.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
description: entry.description.slice(0, MAX_SEARCH_DESCRIPTION),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mcp_describe: tool({
|
||||||
|
description: "Return the full description and input schema for one MCP tool found with mcp_search.",
|
||||||
|
inputSchema: jsonSchema<{ id: string }>(
|
||||||
|
input.transformSchema({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "Exact MCP tool ID returned by mcp_search." },
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async execute(args: { id: string }) {
|
||||||
|
const entry = resolve(entries, args.id)
|
||||||
|
return {
|
||||||
|
title: entry.id,
|
||||||
|
metadata: { id: entry.id },
|
||||||
|
output: JSON.stringify({ id: entry.id, description: entry.description, inputSchema: entry.schema }, null, 2),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mcp_call: tool({
|
||||||
|
description:
|
||||||
|
"Call an MCP tool by exact ID. Inspect unfamiliar tools with mcp_describe first. The underlying MCP tool's permissions and lifecycle hooks still apply.",
|
||||||
|
inputSchema: jsonSchema<{ id: string; args?: Record<string, unknown> }>(
|
||||||
|
input.transformSchema({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "Exact MCP tool ID returned by mcp_search." },
|
||||||
|
args: {
|
||||||
|
type: "object",
|
||||||
|
description: "Arguments matching the input schema returned by mcp_describe.",
|
||||||
|
additionalProperties: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
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`)
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
for (const item of Object.values(result)) controls.add(item)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolve(entries: Map<string, Entry>, id: string) {
|
||||||
|
const requested = id.trim()
|
||||||
|
const entry = entries.get(requested)
|
||||||
|
if (entry) return entry
|
||||||
|
const suggestions = search([...entries.values()], requested.replaceAll(/[^a-zA-Z0-9]+/g, "_"), 3).map(
|
||||||
|
(item) => item.id,
|
||||||
|
)
|
||||||
|
const hint = suggestions.length ? ` Did you mean: ${suggestions.join(", ")}?` : ""
|
||||||
|
throw new Error(`Unknown MCP tool "${id}".${hint}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function search(entries: Entry[], query: string, limit: number) {
|
||||||
|
return fuzzysort.go(query, entries, { keys: ["id", "description", "parameters"], limit }).map((item) => item.obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as McpToolSearch from "./tool-search"
|
||||||
|
|
@ -14,6 +14,8 @@ import { Effect, Record } from "effect"
|
||||||
import { jsonSchema, tool as aiTool, type ModelMessage, type Tool } from "ai"
|
import { jsonSchema, tool as aiTool, type ModelMessage, type Tool } from "ai"
|
||||||
import type { Plugin } from "@/plugin"
|
import type { Plugin } from "@/plugin"
|
||||||
import { mergeDeep } from "remeda"
|
import { mergeDeep } from "remeda"
|
||||||
|
import { Wildcard } from "@opencode-ai/core/util/wildcard"
|
||||||
|
import { McpToolSearch } from "@/mcp/tool-search"
|
||||||
|
|
||||||
const USER_AGENT = `opencode/${InstallationVersion}`
|
const USER_AGENT = `opencode/${InstallationVersion}`
|
||||||
|
|
||||||
|
|
@ -195,12 +197,15 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function resolveTools(input: Pick<PrepareInput, "tools" | "agent" | "permission" | "user">) {
|
export function resolveTools(input: Pick<PrepareInput, "tools" | "agent" | "permission" | "user">) {
|
||||||
const disabled = Permission.disabled(
|
const ruleset = Permission.merge(input.agent.permission, input.permission ?? [])
|
||||||
Object.keys(input.tools),
|
const disabled = Permission.disabled(Object.keys(input.tools), ruleset)
|
||||||
Permission.merge(input.agent.permission, input.permission ?? []),
|
return Record.filter(input.tools, (item, key) => {
|
||||||
)
|
if (input.user.tools?.[key] === false) return false
|
||||||
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
|
if (!disabled.has(key)) return true
|
||||||
|
if (!McpToolSearch.isControl(item)) return false
|
||||||
|
return ruleset.findLast((rule) => Wildcard.match(key, rule.permission))?.permission === "*"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasToolCalls(messages: ModelMessage[]): boolean {
|
export function hasToolCalls(messages: ModelMessage[]): boolean {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { Truncate } from "@/tool/truncate"
|
||||||
|
|
||||||
import { Plugin } from "@/plugin"
|
import { Plugin } from "@/plugin"
|
||||||
import type { TaskPromptOps } from "@/tool/task"
|
import type { TaskPromptOps } from "@/tool/task"
|
||||||
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
|
import { type Tool as AITool, tool, jsonSchema, type JSONSchema7, type ToolExecutionOptions, asSchema } from "ai"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { MessageV2 } from "./message-v2"
|
import { MessageV2 } from "./message-v2"
|
||||||
import { Session } from "./session"
|
import { Session } from "./session"
|
||||||
|
|
@ -21,6 +21,7 @@ import { EffectBridge } from "@/effect/bridge"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
|
import { McpToolSearch } from "@/mcp/tool-search"
|
||||||
|
|
||||||
const MCP_RESOURCE_TOOLS = {
|
const MCP_RESOURCE_TOOLS = {
|
||||||
list: "list_mcp_resources",
|
list: "list_mcp_resources",
|
||||||
|
|
@ -381,12 +382,17 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mcpTools: Record<string, AITool> = {}
|
||||||
|
const mcpSchemas: Record<string, JSONSchema7> = {}
|
||||||
|
const mcpProviderSchemas: Record<string, JSONSchema7> = {}
|
||||||
for (const [key, item] of Object.entries(yield* mcp.tools())) {
|
for (const [key, item] of Object.entries(yield* mcp.tools())) {
|
||||||
const execute = item.execute
|
const execute = item.execute
|
||||||
if (!execute) continue
|
if (!execute) continue
|
||||||
|
|
||||||
const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
|
const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
|
||||||
|
mcpSchemas[key] = { ...schema, properties: schema.properties ?? {} }
|
||||||
const transformed = ProviderTransform.schema(input.model, { ...schema, properties: schema.properties ?? {} })
|
const transformed = ProviderTransform.schema(input.model, { ...schema, properties: schema.properties ?? {} })
|
||||||
|
mcpProviderSchemas[key] = transformed
|
||||||
item.inputSchema = jsonSchema(transformed)
|
item.inputSchema = jsonSchema(transformed)
|
||||||
item.execute = (args, opts) =>
|
item.execute = (args, opts) =>
|
||||||
run.promise(
|
run.promise(
|
||||||
|
|
@ -479,9 +485,35 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||||
return output
|
return output
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
tools[key] = item
|
mcpTools[key] = item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const disabled = Permission.disabled(
|
||||||
|
Object.keys(mcpTools),
|
||||||
|
Permission.merge(input.agent.permission, input.session.permission ?? []),
|
||||||
|
)
|
||||||
|
const user = input.messages.findLast((message) => message.info.role === "user")
|
||||||
|
const overrides = user?.info.role === "user" ? user.info.tools : undefined
|
||||||
|
const searchable = Object.fromEntries(
|
||||||
|
Object.entries(mcpTools).filter(([key]) => overrides?.[key] !== false && !disabled.has(key)),
|
||||||
|
)
|
||||||
|
const providerSchemas = Object.fromEntries(Object.keys(searchable).map((key) => [key, mcpProviderSchemas[key]]))
|
||||||
|
if (!McpToolSearch.shouldUse(searchable, providerSchemas)) {
|
||||||
|
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({
|
||||||
|
tools: searchable,
|
||||||
|
schemas,
|
||||||
|
transformSchema: (schema) => ProviderTransform.schema(input.model, schema),
|
||||||
|
})
|
||||||
|
Object.assign(tools, controls)
|
||||||
return tools
|
return tools
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
174
packages/opencode/test/mcp/tool-search.test.ts
Normal file
174
packages/opencode/test/mcp/tool-search.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { jsonSchema, tool } from "ai"
|
||||||
|
import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||||
|
import { McpToolSearch } from "../../src/mcp/tool-search"
|
||||||
|
|
||||||
|
function target(name: string, description: string, properties: JSONSchema7["properties"] = {}) {
|
||||||
|
return tool({
|
||||||
|
description,
|
||||||
|
inputSchema: jsonSchema({
|
||||||
|
type: "object",
|
||||||
|
properties,
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(args) {
|
||||||
|
return { title: name, metadata: { name }, output: JSON.stringify(args) }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function catalog() {
|
||||||
|
return McpToolSearch.create({
|
||||||
|
tools: {
|
||||||
|
github_create_issue: target("github_create_issue", "Create an issue in a GitHub repository", {
|
||||||
|
title: { type: "string" },
|
||||||
|
}),
|
||||||
|
playwright_take_screenshot: target("playwright_take_screenshot", "Capture the browser page", {
|
||||||
|
fullPage: { type: "boolean" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
schemas: {
|
||||||
|
github_create_issue: {
|
||||||
|
type: "object",
|
||||||
|
properties: { title: { type: "string" } },
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
playwright_take_screenshot: {
|
||||||
|
type: "object",
|
||||||
|
properties: { fullPage: { type: "boolean" } },
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transformSchema: (schema) => schema,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("MCP tool search", () => {
|
||||||
|
test("uses direct tools below the token threshold", async () => {
|
||||||
|
const tools = {
|
||||||
|
github_create_issue: target("github_create_issue", "Create an issue"),
|
||||||
|
}
|
||||||
|
const schemas = { github_create_issue: { type: "object", properties: {} } } satisfies Record<string, JSONSchema7>
|
||||||
|
expect(McpToolSearch.shouldUse(tools, schemas)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses search above the token threshold", async () => {
|
||||||
|
const tools = {
|
||||||
|
large_catalog: target("large_catalog", "x".repeat(60_000)),
|
||||||
|
}
|
||||||
|
const schemas = { large_catalog: { type: "object", properties: {} } } satisfies Record<string, JSONSchema7>
|
||||||
|
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"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("searches names, descriptions, and parameter names", async () => {
|
||||||
|
const tools = await catalog()
|
||||||
|
const result = (await tools.mcp_search!.execute?.(
|
||||||
|
{ query: "fullPage" },
|
||||||
|
{ toolCallId: "search", messages: [], abortSignal: new AbortController().signal },
|
||||||
|
)) as { output: string }
|
||||||
|
expect(result.output).toContain("playwright_take_screenshot")
|
||||||
|
expect(result.output).not.toContain("github_create_issue")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("fuzzy matches misspelled tool names", async () => {
|
||||||
|
const tools = await catalog()
|
||||||
|
const result = (await tools.mcp_search!.execute?.(
|
||||||
|
{ query: "screeshot" },
|
||||||
|
{ toolCallId: "search", messages: [], abortSignal: new AbortController().signal },
|
||||||
|
)) as { output: string }
|
||||||
|
expect(result.output).toContain("playwright_take_screenshot")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("describes the full target schema", async () => {
|
||||||
|
const tools = await catalog()
|
||||||
|
const result = (await tools.mcp_describe!.execute?.(
|
||||||
|
{ id: "github_create_issue" },
|
||||||
|
{ toolCallId: "describe", messages: [], abortSignal: new AbortController().signal },
|
||||||
|
)) as { output: string }
|
||||||
|
expect(result.output).toContain('"title"')
|
||||||
|
expect(result.output).toContain('"type": "string"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test("describes the original schema before provider transforms", async () => {
|
||||||
|
const tools = await McpToolSearch.create({
|
||||||
|
tools: { search: target("search", "Search") },
|
||||||
|
schemas: {
|
||||||
|
search: {
|
||||||
|
type: "object",
|
||||||
|
properties: { query: { type: "string", pattern: "^[a-z]+$" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transformSchema: (schema) => schema,
|
||||||
|
})
|
||||||
|
const result = (await tools.mcp_describe!.execute?.(
|
||||||
|
{ id: "search" },
|
||||||
|
{ toolCallId: "describe", messages: [], abortSignal: new AbortController().signal },
|
||||||
|
)) as { output: string }
|
||||||
|
expect(result.output).toContain('"pattern": "^[a-z]+$"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test("calls the hidden target with structured arguments", async () => {
|
||||||
|
const tools = await catalog()
|
||||||
|
const result = (await tools.mcp_call!.execute?.(
|
||||||
|
{ id: "github_create_issue", args: { title: "Cache bug" } },
|
||||||
|
{ toolCallId: "call", messages: [], abortSignal: new AbortController().signal },
|
||||||
|
)) as { output: string }
|
||||||
|
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: {
|
||||||
|
github___create_issue: target("github___create_issue", "Create an issue"),
|
||||||
|
},
|
||||||
|
schemas: { github___create_issue: { type: "object", properties: {} } },
|
||||||
|
transformSchema: (schema) => schema,
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
tools.mcp_call!.execute?.(
|
||||||
|
{ id: "GitHub/create-issue", args: { title: "Cache bug" } },
|
||||||
|
{ toolCallId: "call", messages: [], abortSignal: new AbortController().signal },
|
||||||
|
),
|
||||||
|
).rejects.toThrow("Did you mean: github___create_issue?")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -25,8 +25,10 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { LLMAISDK } from "@/session/llm/ai-sdk"
|
import { LLMAISDK } from "@/session/llm/ai-sdk"
|
||||||
import { Session as SessionNs } from "@/session/session"
|
import { Session as SessionNs } from "@/session/session"
|
||||||
|
import { LLMRequestPrep } from "@/session/llm/request"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
|
import { McpToolSearch } from "@/mcp/tool-search"
|
||||||
|
|
||||||
type ConfigModel = NonNullable<NonNullable<ConfigV1.Info["provider"]>[string]["models"]>[string]
|
type ConfigModel = NonNullable<NonNullable<ConfigV1.Info["provider"]>[string]["models"]>[string]
|
||||||
|
|
||||||
|
|
@ -173,6 +175,93 @@ describe("session.llm.hasToolCalls", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("session.llm.resolveTools", () => {
|
||||||
|
test("keeps MCP search controls under a generic deny rule", () => {
|
||||||
|
const controls = McpToolSearch.create({
|
||||||
|
tools: { target: tool({ inputSchema: z.object({}), execute: async () => ({ output: "" }) }) },
|
||||||
|
schemas: { target: { type: "object", properties: {} } },
|
||||||
|
transformSchema: (schema) => schema,
|
||||||
|
})
|
||||||
|
const agent = {
|
||||||
|
name: "test",
|
||||||
|
mode: "primary",
|
||||||
|
options: {},
|
||||||
|
permission: [
|
||||||
|
{ permission: "*", pattern: "*", action: "deny" },
|
||||||
|
{ permission: "github_create_issue", pattern: "*", action: "allow" },
|
||||||
|
],
|
||||||
|
} satisfies Agent.Info
|
||||||
|
const user = {
|
||||||
|
id: MessageID.make("msg_mcp_tool_search"),
|
||||||
|
sessionID: SessionID.make("ses_mcp_tool_search"),
|
||||||
|
role: "user",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent: agent.name,
|
||||||
|
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
|
||||||
|
} satisfies SessionV1.User
|
||||||
|
expect(
|
||||||
|
Object.keys(
|
||||||
|
LLMRequestPrep.resolveTools({
|
||||||
|
tools: controls,
|
||||||
|
agent,
|
||||||
|
user,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual(["mcp_search", "mcp_describe", "mcp_call"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("honors explicit MCP search control denies", () => {
|
||||||
|
const controls = McpToolSearch.create({
|
||||||
|
tools: { target: tool({ inputSchema: z.object({}), execute: async () => ({ output: "" }) }) },
|
||||||
|
schemas: { target: { type: "object", properties: {} } },
|
||||||
|
transformSchema: (schema) => schema,
|
||||||
|
})
|
||||||
|
const agent = {
|
||||||
|
name: "test",
|
||||||
|
mode: "primary",
|
||||||
|
options: {},
|
||||||
|
permission: [
|
||||||
|
{ permission: "*", pattern: "*", action: "deny" },
|
||||||
|
{ permission: "mcp_*", pattern: "*", action: "deny" },
|
||||||
|
],
|
||||||
|
} satisfies Agent.Info
|
||||||
|
const user = {
|
||||||
|
id: MessageID.make("msg_mcp_tool_search_deny"),
|
||||||
|
sessionID: SessionID.make("ses_mcp_tool_search_deny"),
|
||||||
|
role: "user",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent: agent.name,
|
||||||
|
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
|
||||||
|
} satisfies SessionV1.User
|
||||||
|
expect(
|
||||||
|
LLMRequestPrep.resolveTools({
|
||||||
|
tools: controls,
|
||||||
|
agent,
|
||||||
|
user,
|
||||||
|
}),
|
||||||
|
).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not exempt plugin tools that use MCP control names", () => {
|
||||||
|
const agent = {
|
||||||
|
name: "test",
|
||||||
|
mode: "primary",
|
||||||
|
options: {},
|
||||||
|
permission: [{ permission: "*", pattern: "*", action: "deny" }],
|
||||||
|
} satisfies Agent.Info
|
||||||
|
const user = {
|
||||||
|
id: MessageID.make("msg_mcp_plugin_tool"),
|
||||||
|
sessionID: SessionID.make("ses_mcp_plugin_tool"),
|
||||||
|
role: "user",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent: agent.name,
|
||||||
|
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
|
||||||
|
} satisfies SessionV1.User
|
||||||
|
const pluginTool = tool({ inputSchema: z.object({}), execute: async () => ({ output: "" }) })
|
||||||
|
expect(LLMRequestPrep.resolveTools({ tools: { mcp_call: pluginTool }, agent, user })).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("session.llm.ai-sdk adapter", () => {
|
describe("session.llm.ai-sdk adapter", () => {
|
||||||
type AISDKAdapterEvent = Parameters<typeof LLMAISDK.toLLMEvents>[1]
|
type AISDKAdapterEvent = Parameters<typeof LLMAISDK.toLLMEvents>[1]
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue