refactor(opencode): consume native MCP tools in code mode
This commit is contained in:
parent
3641fec04f
commit
02696a7a5e
2 changed files with 100 additions and 132 deletions
|
|
@ -1,6 +1,5 @@
|
||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
import type { Tool as AITool, ToolExecutionOptions } from "ai"
|
import { CallToolResultSchema, type CallToolResult } from "@modelcontextprotocol/sdk/types.js"
|
||||||
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
|
|
||||||
import { Cause, Effect, Schema } from "effect"
|
import { Cause, Effect, Schema } from "effect"
|
||||||
import {
|
import {
|
||||||
CodeMode,
|
CodeMode,
|
||||||
|
|
@ -48,57 +47,34 @@ type CatalogEntry = {
|
||||||
key: string
|
key: string
|
||||||
server: string
|
server: string
|
||||||
local: string
|
local: string
|
||||||
description: string
|
tool: MCP.McpTool
|
||||||
tool: AITool
|
|
||||||
inputSchema: JsonSchema
|
|
||||||
outputSchema?: JsonSchema
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toJsonSchema = (schema: unknown): JsonSchema => schema as JsonSchema
|
const toJsonSchema = (schema: unknown): JsonSchema => schema as JsonSchema
|
||||||
|
|
||||||
function groupByServer(
|
function groupByServer(mcpTools: Record<string, MCP.McpTool>, servers: readonly string[]): Map<string, CatalogEntry[]> {
|
||||||
mcpTools: Record<string, AITool>,
|
|
||||||
servers: readonly string[],
|
|
||||||
mcpDefs: Record<string, MCPToolDef> = {},
|
|
||||||
): Map<string, CatalogEntry[]> {
|
|
||||||
const byLongest = [...servers].sort((a, b) => b.length - a.length)
|
const byLongest = [...servers].sort((a, b) => b.length - a.length)
|
||||||
const groups = new Map<string, CatalogEntry[]>()
|
const groups = new Map<string, CatalogEntry[]>()
|
||||||
for (const key of Object.keys(mcpTools).sort((a, b) => a.localeCompare(b))) {
|
for (const key of Object.keys(mcpTools).sort((a, b) => a.localeCompare(b))) {
|
||||||
const server =
|
const server =
|
||||||
byLongest.find((name) => key.startsWith(name + "_")) ?? (key.includes("_") ? key.slice(0, key.indexOf("_")) : key)
|
byLongest.find((name) => key.startsWith(name + "_")) ?? (key.includes("_") ? key.slice(0, key.indexOf("_")) : key)
|
||||||
const local = server && key.startsWith(server + "_") ? key.slice(server.length + 1) : key
|
const local = server && key.startsWith(server + "_") ? key.slice(server.length + 1) : key
|
||||||
const tool = mcpTools[key]!
|
|
||||||
const def = mcpDefs[key]
|
|
||||||
const schema = (tool.inputSchema as { jsonSchema?: unknown } | undefined)?.jsonSchema
|
|
||||||
const entry: CatalogEntry = {
|
const entry: CatalogEntry = {
|
||||||
path: `${server}.${local}`,
|
path: `${server}.${local}`,
|
||||||
key,
|
key,
|
||||||
server,
|
server,
|
||||||
local,
|
local,
|
||||||
description: tool.description ?? def?.description ?? "",
|
tool: mcpTools[key]!,
|
||||||
tool,
|
|
||||||
inputSchema: def?.inputSchema
|
|
||||||
? toJsonSchema(def.inputSchema)
|
|
||||||
: schema && typeof schema === "object"
|
|
||||||
? toJsonSchema(schema)
|
|
||||||
: { type: "object", properties: {} },
|
|
||||||
...(def?.outputSchema ? { outputSchema: toJsonSchema(def.outputSchema) } : {}),
|
|
||||||
}
|
}
|
||||||
groups.set(server, [...(groups.get(server) ?? []), entry])
|
groups.set(server, [...(groups.get(server) ?? []), entry])
|
||||||
}
|
}
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
export function describeCatalog(
|
export function describeCatalog(mcpTools: Record<string, MCP.McpTool>, servers: readonly string[]): string {
|
||||||
mcpTools: Record<string, AITool>,
|
|
||||||
mcpDefs: Record<string, MCPToolDef>,
|
|
||||||
servers: readonly string[],
|
|
||||||
): string {
|
|
||||||
return CodeMode.make({
|
return CodeMode.make({
|
||||||
tools: toolTree(
|
tools: toolTree(
|
||||||
[...groupByServer(mcpTools, servers, mcpDefs).values()]
|
[...groupByServer(mcpTools, servers).values()].flat(),
|
||||||
.flat()
|
|
||||||
.filter((entry) => entry.tool.execute !== undefined),
|
|
||||||
() => () => Effect.fail(toolError("Tool preview is not executable.")),
|
() => () => Effect.fail(toolError("Tool preview is not executable.")),
|
||||||
),
|
),
|
||||||
}).instructions()
|
}).instructions()
|
||||||
|
|
@ -112,10 +88,7 @@ const lastSegment = (uri: string) => {
|
||||||
|
|
||||||
const dataUrl = (mime: string, base64: string) => `data:${mime};base64,${base64}`
|
const dataUrl = (mime: string, base64: string) => `data:${mime};base64,${base64}`
|
||||||
|
|
||||||
function projectMcpResult(raw: unknown, collect: (attachment: Attachment) => void): unknown {
|
function projectMcpResult(result: CallToolResult, collect: (attachment: Attachment) => void): unknown {
|
||||||
if (raw === null || typeof raw !== "object") return raw
|
|
||||||
const record = raw as { structuredContent?: unknown; content?: unknown }
|
|
||||||
const content = Array.isArray(record.content) ? record.content : []
|
|
||||||
const text: string[] = []
|
const text: string[] = []
|
||||||
let files = 0
|
let files = 0
|
||||||
let images = 0
|
let images = 0
|
||||||
|
|
@ -124,53 +97,42 @@ function projectMcpResult(raw: unknown, collect: (attachment: Attachment) => voi
|
||||||
if (attachment.mime.startsWith("image/")) images += 1
|
if (attachment.mime.startsWith("image/")) images += 1
|
||||||
collect(attachment)
|
collect(attachment)
|
||||||
}
|
}
|
||||||
for (const item of content) {
|
for (const block of result.content) {
|
||||||
if (!item || typeof item !== "object") continue
|
|
||||||
const block = item as Record<string, unknown>
|
|
||||||
switch (block.type) {
|
switch (block.type) {
|
||||||
case "text":
|
case "text":
|
||||||
if (typeof block.text === "string") text.push(block.text)
|
text.push(block.text)
|
||||||
break
|
break
|
||||||
case "image":
|
case "image":
|
||||||
case "audio":
|
case "audio":
|
||||||
if (typeof block.data === "string" && typeof block.mimeType === "string") {
|
push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) })
|
||||||
push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) })
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
case "resource": {
|
case "resource": {
|
||||||
const res = block.resource as Record<string, unknown> | undefined
|
if ("text" in block.resource) {
|
||||||
if (res && typeof res === "object") {
|
text.push(block.resource.text)
|
||||||
const mime = typeof res.mimeType === "string" ? res.mimeType : "application/octet-stream"
|
break
|
||||||
const uri = typeof res.uri === "string" ? res.uri : undefined
|
|
||||||
if (typeof res.blob === "string") {
|
|
||||||
push({ type: "file", mime, url: dataUrl(mime, res.blob), filename: uri ? lastSegment(uri) : undefined })
|
|
||||||
} else if (typeof res.text === "string") {
|
|
||||||
text.push(res.text)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const mime = block.resource.mimeType ?? "application/octet-stream"
|
||||||
|
push({ type: "file", mime, url: dataUrl(mime, block.resource.blob), filename: lastSegment(block.resource.uri) })
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "resource_link":
|
case "resource_link":
|
||||||
if (typeof block.uri === "string") {
|
push({
|
||||||
push({
|
type: "file",
|
||||||
type: "file",
|
mime: block.mimeType ?? "application/octet-stream",
|
||||||
mime: typeof block.mimeType === "string" ? block.mimeType : "application/octet-stream",
|
url: block.uri,
|
||||||
url: block.uri,
|
filename: block.name,
|
||||||
filename: typeof block.name === "string" ? block.name : lastSegment(block.uri),
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (record.structuredContent !== undefined && record.structuredContent !== null) return record.structuredContent
|
if (result.structuredContent !== undefined && result.structuredContent !== null) return result.structuredContent
|
||||||
if (text.length > 0) return text.join("\n")
|
if (text.length > 0) return text.join("\n")
|
||||||
if (files > 0) {
|
if (files > 0) {
|
||||||
const noun = files === images ? "image" : "file"
|
const noun = files === images ? "image" : "file"
|
||||||
return `[${files} ${noun}${files === 1 ? "" : "s"} attached to the result]`
|
return `[${files} ${noun}${files === 1 ? "" : "s"} attached to the result]`
|
||||||
}
|
}
|
||||||
if (Array.isArray(record.content)) return null // MCP-shaped result with nothing extractable
|
return null
|
||||||
return raw
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Run = (input: unknown) => Effect.Effect<unknown, unknown>
|
type Run = (input: unknown) => Effect.Effect<unknown, unknown>
|
||||||
|
|
@ -180,32 +142,51 @@ function toolTree(catalog: readonly CatalogEntry[], run: (entry: CatalogEntry) =
|
||||||
for (const entry of catalog) {
|
for (const entry of catalog) {
|
||||||
const namespace = (tree[entry.server] ??= {})
|
const namespace = (tree[entry.server] ??= {})
|
||||||
namespace[entry.local] = SandboxTool.make({
|
namespace[entry.local] = SandboxTool.make({
|
||||||
description: entry.description,
|
description: entry.tool.def.description ?? "",
|
||||||
input: entry.inputSchema,
|
input: toJsonSchema(entry.tool.def.inputSchema),
|
||||||
output: entry.outputSchema,
|
output: entry.tool.def.outputSchema ? toJsonSchema(entry.tool.def.outputSchema) : undefined,
|
||||||
run: run(entry),
|
run: run(entry),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return tree
|
return tree
|
||||||
}
|
}
|
||||||
|
|
||||||
const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* <R>(input: {
|
const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input: {
|
||||||
plugin: Plugin.Interface
|
plugin: Plugin.Interface
|
||||||
entry: CatalogEntry
|
entry: CatalogEntry
|
||||||
args: any
|
args: Record<string, unknown>
|
||||||
callID: string
|
callID: string
|
||||||
options: ToolExecutionOptions
|
|
||||||
ctx: Tool.Context
|
ctx: Tool.Context
|
||||||
execute: (args: any, options: ToolExecutionOptions) => R | PromiseLike<R>
|
|
||||||
}) {
|
}) {
|
||||||
yield* input.plugin.trigger(
|
yield* input.plugin.trigger(
|
||||||
"tool.execute.before",
|
"tool.execute.before",
|
||||||
{ tool: input.entry.key, sessionID: input.ctx.sessionID, callID: input.callID },
|
{ tool: input.entry.key, sessionID: input.ctx.sessionID, callID: input.callID },
|
||||||
{ args: input.args },
|
{ args: input.args },
|
||||||
)
|
)
|
||||||
const result: R = yield* Effect.gen(function* () {
|
const result: CallToolResult = yield* Effect.gen(function* () {
|
||||||
yield* input.ctx.ask({ permission: input.entry.key, metadata: {}, patterns: ["*"], always: ["*"] })
|
yield* input.ctx.ask({ permission: input.entry.key, metadata: {}, patterns: ["*"], always: ["*"] })
|
||||||
return yield* Effect.promise(() => Promise.resolve(input.execute(input.args, input.options)))
|
// Deliberately mirrors McpCatalog.convertTool's transport call so the MCP service stays free of tool-loop concerns.
|
||||||
|
return yield* Effect.promise(async () => {
|
||||||
|
const raw = await input.entry.tool.client.callTool(
|
||||||
|
{ name: input.entry.tool.def.name, arguments: input.args },
|
||||||
|
CallToolResultSchema,
|
||||||
|
{
|
||||||
|
resetTimeoutOnProgress: true,
|
||||||
|
signal: input.ctx.abort,
|
||||||
|
timeout: input.entry.tool.timeout,
|
||||||
|
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
|
||||||
|
onprogress: () => {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (raw.isError)
|
||||||
|
throw new Error(
|
||||||
|
raw.content
|
||||||
|
.flatMap((item) => (item.type === "text" ? [item.text] : []))
|
||||||
|
.filter((text) => text.trim())
|
||||||
|
.join("\n\n") || "MCP tool returned an error",
|
||||||
|
)
|
||||||
|
return raw
|
||||||
|
})
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.withSpan("Tool.execute", {
|
Effect.withSpan("Tool.execute", {
|
||||||
attributes: {
|
attributes: {
|
||||||
|
|
@ -248,9 +229,7 @@ export const CodeModeTool = Tool.define(
|
||||||
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
|
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
|
||||||
const mcpTools = Permission.visibleTools(yield* mcp.tools(), ruleset)
|
const mcpTools = Permission.visibleTools(yield* mcp.tools(), ruleset)
|
||||||
const servers = Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize)
|
const servers = Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize)
|
||||||
const catalog = [...groupByServer(mcpTools, servers, yield* mcp.defs()).values()]
|
const catalog = [...groupByServer(mcpTools, servers).values()].flat()
|
||||||
.flat()
|
|
||||||
.filter((entry) => entry.tool.execute !== undefined)
|
|
||||||
|
|
||||||
const calls: CallEntry[] = []
|
const calls: CallEntry[] = []
|
||||||
const attachments: Attachment[] = []
|
const attachments: Attachment[] = []
|
||||||
|
|
@ -262,16 +241,14 @@ export const CodeModeTool = Tool.define(
|
||||||
const callTool = (entry: CatalogEntry) => (input: unknown) =>
|
const callTool = (entry: CatalogEntry) => (input: unknown) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
childCalls += 1
|
childCalls += 1
|
||||||
const raw = yield* invokeChildTool({
|
const result = yield* invokeChildTool({
|
||||||
plugin,
|
plugin,
|
||||||
entry,
|
entry,
|
||||||
args: input ?? {},
|
args: (input ?? {}) as Record<string, unknown>,
|
||||||
callID: `${ctx.callID ?? entry.key}/${childCalls}`,
|
callID: `${ctx.callID ?? entry.key}/${childCalls}`,
|
||||||
options: { toolCallId: ctx.callID ?? entry.key, abortSignal: ctx.abort, messages: [] },
|
|
||||||
ctx,
|
ctx,
|
||||||
execute: entry.tool.execute!,
|
|
||||||
})
|
})
|
||||||
return projectMcpResult(raw, collect)
|
return projectMcpResult(result, collect)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catchCause((cause) => {
|
Effect.catchCause((cause) => {
|
||||||
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
|
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,7 @@ import { Plugin } from "@/plugin"
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import { Tool } from "@/tool/tool"
|
import { Tool } from "@/tool/tool"
|
||||||
import * as Truncate from "@/tool/truncate"
|
import * as Truncate from "@/tool/truncate"
|
||||||
import { McpCatalog } from "@/mcp/catalog"
|
|
||||||
import { MessageID, SessionID } from "@/session/schema"
|
import { MessageID, SessionID } from "@/session/schema"
|
||||||
import type { Tool as AITool } from "ai"
|
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
|
|
||||||
const ctx: Tool.Context = {
|
const ctx: Tool.Context = {
|
||||||
|
|
@ -34,16 +32,18 @@ function mcpTool(
|
||||||
name: string,
|
name: string,
|
||||||
handler: (args: Record<string, unknown>) => unknown,
|
handler: (args: Record<string, unknown>) => unknown,
|
||||||
inputSchema: Record<string, unknown> = { type: "object", properties: {} },
|
inputSchema: Record<string, unknown> = { type: "object", properties: {} },
|
||||||
): AITool {
|
outputSchema?: Record<string, unknown>,
|
||||||
const client = {
|
): MCP.McpTool {
|
||||||
callTool: async (params: { arguments?: Record<string, unknown> }) => handler(params.arguments ?? {}),
|
return {
|
||||||
|
def: { name, description: name, inputSchema, ...(outputSchema ? { outputSchema } : {}) } as MCPToolDef,
|
||||||
|
client: {
|
||||||
|
callTool: async (params: { arguments?: Record<string, unknown> }) => handler(params.arguments ?? {}),
|
||||||
|
} as unknown as MCP.McpTool["client"],
|
||||||
}
|
}
|
||||||
return McpCatalog.convertTool({ name, description: name, inputSchema } as any, client as any)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function harness(input: {
|
function harness(input: {
|
||||||
mcpTools: Record<string, AITool>
|
mcpTools: Record<string, MCP.McpTool>
|
||||||
defs?: Record<string, MCPToolDef>
|
|
||||||
servers: string[]
|
servers: string[]
|
||||||
permission?: PermissionV1.Rule[]
|
permission?: PermissionV1.Rule[]
|
||||||
trigger?: Plugin.Interface["trigger"]
|
trigger?: Plugin.Interface["trigger"]
|
||||||
|
|
@ -63,19 +63,17 @@ function harness(input: {
|
||||||
}),
|
}),
|
||||||
Layer.mock(MCP.Service, {
|
Layer.mock(MCP.Service, {
|
||||||
tools: () => Effect.succeed(input.mcpTools),
|
tools: () => Effect.succeed(input.mcpTools),
|
||||||
defs: () => Effect.succeed(input.defs ?? {}),
|
|
||||||
clients: () => Effect.succeed(Object.fromEntries(input.servers.map((name) => [name, {} as any]))),
|
clients: () => Effect.succeed(Object.fromEntries(input.servers.map((name) => [name, {} as any]))),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function serverNames(mcpTools: Record<string, AITool>, servers?: string[]) {
|
function serverNames(mcpTools: Record<string, MCP.McpTool>, servers?: string[]) {
|
||||||
return servers ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))]
|
return servers ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))]
|
||||||
}
|
}
|
||||||
|
|
||||||
function build(
|
function build(
|
||||||
mcpTools: Record<string, AITool>,
|
mcpTools: Record<string, MCP.McpTool>,
|
||||||
defs: Record<string, MCPToolDef> = {},
|
|
||||||
servers?: string[],
|
servers?: string[],
|
||||||
permission?: PermissionV1.Rule[],
|
permission?: PermissionV1.Rule[],
|
||||||
trigger?: Plugin.Interface["trigger"],
|
trigger?: Plugin.Interface["trigger"],
|
||||||
|
|
@ -84,18 +82,13 @@ function build(
|
||||||
return Effect.runPromise(
|
return Effect.runPromise(
|
||||||
CodeModeTool.pipe(
|
CodeModeTool.pipe(
|
||||||
Effect.flatMap(Tool.init),
|
Effect.flatMap(Tool.init),
|
||||||
Effect.provide(harness({ mcpTools, defs, servers: names, permission, trigger })),
|
Effect.provide(harness({ mcpTools, servers: names, permission, trigger })),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeFor(
|
function describeFor(mcpTools: Record<string, MCP.McpTool>, servers?: string[], permission: PermissionV1.Rule[] = []) {
|
||||||
mcpTools: Record<string, AITool>,
|
return describeCatalog(Permission.visibleTools(mcpTools, permission), serverNames(mcpTools, servers))
|
||||||
defs: Record<string, MCPToolDef> = {},
|
|
||||||
servers?: string[],
|
|
||||||
permission: PermissionV1.Rule[] = [],
|
|
||||||
) {
|
|
||||||
return describeCatalog(Permission.visibleTools(mcpTools, permission), defs, serverNames(mcpTools, servers))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("code mode execute", () => {
|
describe("code mode execute", () => {
|
||||||
|
|
@ -106,26 +99,29 @@ describe("code mode execute", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test("groups multi-underscore server names by longest matching prefix", () => {
|
test("groups multi-underscore server names by longest matching prefix", () => {
|
||||||
const description = describeFor({ my_server_do_thing: mcpTool("do_thing", () => "") }, {}, ["my_server"])
|
const description = describeFor({ my_server_do_thing: mcpTool("do_thing", () => "") }, ["my_server"])
|
||||||
expect(description).toContain("- my_server (1 tool)")
|
expect(description).toContain("- my_server (1 tool)")
|
||||||
expect(description).toContain("tools.my_server.do_thing(")
|
expect(description).toContain("tools.my_server.do_thing(")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("groupByServer uses the whole key as the server name when it has no underscore", () => {
|
test("groupByServer uses the whole key as the server name when it has no underscore", () => {
|
||||||
const description = describeFor({ standalone: mcpTool("standalone", () => "") }, {}, [])
|
const description = describeFor({ standalone: mcpTool("standalone", () => "") }, [])
|
||||||
expect(description).toContain("- standalone (1 tool)")
|
expect(description).toContain("- standalone (1 tool)")
|
||||||
expect(description).toContain("tools.standalone.standalone(")
|
expect(description).toContain("tools.standalone.standalone(")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("describeCatalog carries the raw MCP schemas for rendering", () => {
|
test("describeCatalog carries the raw MCP schemas for rendering", () => {
|
||||||
const defs: Record<string, MCPToolDef> = {
|
const description = describeFor(
|
||||||
weather_current: {
|
{
|
||||||
name: "current",
|
weather_current: mcpTool(
|
||||||
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
"current",
|
||||||
outputSchema: { type: "object", properties: { tempC: { type: "number" } }, required: ["tempC"] },
|
() => "",
|
||||||
} as any,
|
{ type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||||
}
|
{ type: "object", properties: { tempC: { type: "number" } }, required: ["tempC"] },
|
||||||
const description = describeFor({ weather_current: mcpTool("current", () => "") }, defs, ["weather"])
|
),
|
||||||
|
},
|
||||||
|
["weather"],
|
||||||
|
)
|
||||||
expect(description).toContain("tools.weather.current(input: { city: string }): Promise<{ tempC: number }>")
|
expect(description).toContain("tools.weather.current(input: { city: string }): Promise<{ tempC: number }>")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -167,43 +163,42 @@ describe("code mode execute", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test("signatures render the declared outputSchema as the return type", () => {
|
test("signatures render the declared outputSchema as the return type", () => {
|
||||||
const defs: Record<string, MCPToolDef> = {
|
const description = describeFor({
|
||||||
weather_current: {
|
weather_current: mcpTool(
|
||||||
name: "current",
|
"current",
|
||||||
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
() => "",
|
||||||
outputSchema: {
|
{ type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||||
|
{
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: { tempC: { type: "number" }, summary: { type: "string" } },
|
properties: { tempC: { type: "number" }, summary: { type: "string" } },
|
||||||
required: ["tempC"],
|
required: ["tempC"],
|
||||||
},
|
},
|
||||||
} as any,
|
),
|
||||||
}
|
})
|
||||||
const description = describeFor({ weather_current: mcpTool("current", () => "") }, defs)
|
|
||||||
expect(description).toContain(
|
expect(description).toContain(
|
||||||
"tools.weather.current(input: { city: string }): Promise<{ tempC: number; summary?: string }>",
|
"tools.weather.current(input: { city: string }): Promise<{ tempC: number; summary?: string }>",
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("large catalogs inline a budgeted PARTIAL list plus runtime search", async () => {
|
test("large catalogs inline a budgeted PARTIAL list plus runtime search", async () => {
|
||||||
const tools: Record<string, AITool> = {}
|
const tools: Record<string, MCP.McpTool> = {}
|
||||||
const filler = "a searchable description of this operation that consumes catalog budget ".repeat(3)
|
const filler = "a searchable description of this operation that consumes catalog budget ".repeat(3)
|
||||||
for (let i = 0; i < 150; i++) {
|
for (let i = 0; i < 150; i++) {
|
||||||
const client = { callTool: async () => ({ content: [] }) }
|
tools[`alpha_op_${i}`] = {
|
||||||
tools[`alpha_op_${i}`] = McpCatalog.convertTool(
|
def: {
|
||||||
{
|
|
||||||
name: `op_${i}`,
|
name: `op_${i}`,
|
||||||
description: `${filler}${i}`,
|
description: `${filler}${i}`,
|
||||||
inputSchema: { type: "object", properties: { value: { type: "string" }, count: { type: "number" } } },
|
inputSchema: { type: "object", properties: { value: { type: "string" }, count: { type: "number" } } },
|
||||||
} as any,
|
} as MCPToolDef,
|
||||||
client as any,
|
client: { callTool: async () => ({ content: [] }) } as unknown as MCP.McpTool["client"],
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
tools["zeta_only_tool"] = mcpTool("only_tool", () => "", {
|
tools["zeta_only_tool"] = mcpTool("only_tool", () => "", {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: { topic: { type: "string", description: "Subject to look up" } },
|
properties: { topic: { type: "string", description: "Subject to look up" } },
|
||||||
required: ["topic"],
|
required: ["topic"],
|
||||||
})
|
})
|
||||||
const description = describeFor(tools, {}, ["alpha", "zeta"])
|
const description = describeFor(tools, ["alpha", "zeta"])
|
||||||
|
|
||||||
expect(description).toContain("Available tools (PARTIAL - ")
|
expect(description).toContain("Available tools (PARTIAL - ")
|
||||||
expect(description).toMatch(/- alpha \(150 tools, \d+ shown\)/)
|
expect(description).toMatch(/- alpha \(150 tools, \d+ shown\)/)
|
||||||
|
|
@ -216,7 +211,7 @@ describe("code mode execute", () => {
|
||||||
expect(description).toContain("tools.alpha.op_0(")
|
expect(description).toContain("tools.alpha.op_0(")
|
||||||
expect(description).not.toContain("tools.alpha.op_99(")
|
expect(description).not.toContain("tools.alpha.op_99(")
|
||||||
|
|
||||||
const tool = await build(tools, {}, ["alpha", "zeta"])
|
const tool = await build(tools, ["alpha", "zeta"])
|
||||||
const out = await Effect.runPromise(
|
const out = await Effect.runPromise(
|
||||||
tool.execute({ code: "return await tools.$codemode.search({ query: 'only tool', limit: 3 })" }, ctx),
|
tool.execute({ code: "return await tools.$codemode.search({ query: 'only tool', limit: 3 })" }, ctx),
|
||||||
)
|
)
|
||||||
|
|
@ -385,7 +380,6 @@ describe("code mode execute", () => {
|
||||||
a_tool: mcpTool("a", () => ({ content: [{ type: "text", text: "one" }] })),
|
a_tool: mcpTool("a", () => ({ content: [{ type: "text", text: "one" }] })),
|
||||||
b_tool: mcpTool("b", () => ({ content: [{ type: "text", text: "two" }] })),
|
b_tool: mcpTool("b", () => ({ content: [{ type: "text", text: "two" }] })),
|
||||||
},
|
},
|
||||||
{},
|
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
trigger,
|
trigger,
|
||||||
|
|
@ -421,7 +415,6 @@ describe("code mode execute", () => {
|
||||||
}
|
}
|
||||||
const tool = await build(
|
const tool = await build(
|
||||||
{ a_tool: mcpTool("a", record("a")), b_tool: mcpTool("b", record("b")) },
|
{ a_tool: mcpTool("a", record("a")), b_tool: mcpTool("b", record("b")) },
|
||||||
{},
|
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
trigger,
|
trigger,
|
||||||
|
|
@ -520,7 +513,7 @@ describe("code mode execute", () => {
|
||||||
media_mixed: mcpTool("mixed", () => ({
|
media_mixed: mcpTool("mixed", () => ({
|
||||||
content: [
|
content: [
|
||||||
{ type: "image", data: "PNG3", mimeType: "image/png" },
|
{ type: "image", data: "PNG3", mimeType: "image/png" },
|
||||||
{ type: "resource_link", uri: "file:///tmp/report.pdf", mimeType: "application/pdf" },
|
{ type: "resource_link", uri: "file:///tmp/report.pdf", name: "report.pdf", mimeType: "application/pdf" },
|
||||||
],
|
],
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
@ -633,7 +626,7 @@ describe("code mode permission visibility", () => {
|
||||||
github_create_issue: mcpTool("create_issue", ok),
|
github_create_issue: mcpTool("create_issue", ok),
|
||||||
github_list_issues: mcpTool("list_issues", ok),
|
github_list_issues: mcpTool("list_issues", ok),
|
||||||
}
|
}
|
||||||
const description = describeFor(mcpTools, {}, ["github"], [deny("github_create_issue")])
|
const description = describeFor(mcpTools, ["github"], [deny("github_create_issue")])
|
||||||
expect(description).toContain("tools.github.list_issues(")
|
expect(description).toContain("tools.github.list_issues(")
|
||||||
expect(description).not.toContain("create_issue")
|
expect(description).not.toContain("create_issue")
|
||||||
expect(description).toContain("- github (1 tool)")
|
expect(description).toContain("- github (1 tool)")
|
||||||
|
|
@ -644,7 +637,7 @@ describe("code mode permission visibility", () => {
|
||||||
github_create_issue: mcpTool("create_issue", ok),
|
github_create_issue: mcpTool("create_issue", ok),
|
||||||
github_list_issues: mcpTool("list_issues", ok),
|
github_list_issues: mcpTool("list_issues", ok),
|
||||||
}
|
}
|
||||||
const description = describeFor(mcpTools, {}, ["github"], [askRule("github_create_issue")])
|
const description = describeFor(mcpTools, ["github"], [askRule("github_create_issue")])
|
||||||
expect(description).toContain("tools.github.create_issue(")
|
expect(description).toContain("tools.github.create_issue(")
|
||||||
expect(description).toContain("tools.github.list_issues(")
|
expect(description).toContain("tools.github.list_issues(")
|
||||||
expect(description).toContain("- github (2 tools)")
|
expect(description).toContain("- github (2 tools)")
|
||||||
|
|
@ -660,7 +653,6 @@ describe("code mode permission visibility", () => {
|
||||||
}),
|
}),
|
||||||
github_list_issues: mcpTool("list_issues", ok),
|
github_list_issues: mcpTool("list_issues", ok),
|
||||||
},
|
},
|
||||||
{},
|
|
||||||
["github"],
|
["github"],
|
||||||
[deny("github_create_issue")],
|
[deny("github_create_issue")],
|
||||||
)
|
)
|
||||||
|
|
@ -685,7 +677,6 @@ describe("code mode permission visibility", () => {
|
||||||
const askCtx: Tool.Context = { ...ctx, ask: (req) => Effect.sync(() => void asked.push(req.permission)) }
|
const askCtx: Tool.Context = { ...ctx, ask: (req) => Effect.sync(() => void asked.push(req.permission)) }
|
||||||
const tool = await build(
|
const tool = await build(
|
||||||
{ github_list_issues: mcpTool("list_issues", ok) },
|
{ github_list_issues: mcpTool("list_issues", ok) },
|
||||||
{},
|
|
||||||
["github"],
|
["github"],
|
||||||
[askRule("github_list_issues")],
|
[askRule("github_list_issues")],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue