refactor(opencode): simplify deferred MCP tools
This commit is contained in:
parent
9a90e14c90
commit
e8b6ff6be3
1 changed files with 239 additions and 336 deletions
|
|
@ -19,11 +19,6 @@ import { Session } from "./session"
|
||||||
import { SessionProcessor } from "./processor"
|
import { SessionProcessor } from "./processor"
|
||||||
import { PartID } from "./schema"
|
import { PartID } from "./schema"
|
||||||
|
|
||||||
const MCP_RESOURCE_TOOLS = {
|
|
||||||
list: "list_mcp_resources",
|
|
||||||
listTemplates: "list_mcp_resource_templates",
|
|
||||||
read: "read_mcp_resource",
|
|
||||||
} as const
|
|
||||||
const DEFERRED_TOOL_TOOLS = {
|
const DEFERRED_TOOL_TOOLS = {
|
||||||
search: "search_deferred_tools",
|
search: "search_deferred_tools",
|
||||||
call: "call_deferred_tool",
|
call: "call_deferred_tool",
|
||||||
|
|
@ -185,7 +180,7 @@ export const resolve = Effect.fn("SessionMcpTools.resolve")(function* (input: In
|
||||||
const output = {
|
const output = {
|
||||||
title: "",
|
title: "",
|
||||||
metadata: {
|
metadata: {
|
||||||
...mcpToolMetadata(rawResult),
|
...(isRecord(rawResult) && isRecord(rawResult.metadata) ? rawResult.metadata : {}),
|
||||||
truncated: truncated.truncated,
|
truncated: truncated.truncated,
|
||||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||||
},
|
},
|
||||||
|
|
@ -220,9 +215,7 @@ export const resolve = Effect.fn("SessionMcpTools.resolve")(function* (input: In
|
||||||
? yield* deferredToolDescriptors(allowedMcpTools)
|
? yield* deferredToolDescriptors(allowedMcpTools)
|
||||||
: []
|
: []
|
||||||
const deferMcpTools =
|
const deferMcpTools =
|
||||||
deferredDescriptors.length > 0 &&
|
deferredDescriptors.length > 0 && deferredToolSchemaTokens(deferredDescriptors) >= MIN_DEFERRED_MCP_SCHEMA_TOKENS
|
||||||
Token.estimate(JSON.stringify(deferredDescriptors.map(deferredToolEstimatePayload))) >=
|
|
||||||
MIN_DEFERRED_MCP_SCHEMA_TOKENS
|
|
||||||
|
|
||||||
if (deferMcpTools) {
|
if (deferMcpTools) {
|
||||||
addDeferredTools(tools, {
|
addDeferredTools(tools, {
|
||||||
|
|
@ -269,10 +262,7 @@ export const systemPrompt = Effect.fn("SessionMcpTools.systemPrompt")(function*
|
||||||
if (Object.keys(allowedTools).length === 0) return undefined
|
if (Object.keys(allowedTools).length === 0) return undefined
|
||||||
|
|
||||||
const deferredDescriptors = yield* deferredToolDescriptors(allowedTools)
|
const deferredDescriptors = yield* deferredToolDescriptors(allowedTools)
|
||||||
if (
|
if (deferredToolSchemaTokens(deferredDescriptors) < MIN_DEFERRED_MCP_SCHEMA_TOKENS) {
|
||||||
Token.estimate(JSON.stringify(deferredDescriptors.map(deferredToolEstimatePayload))) <
|
|
||||||
MIN_DEFERRED_MCP_SCHEMA_TOKENS
|
|
||||||
) {
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -301,169 +291,120 @@ function addResourceTools(
|
||||||
context: (args: Record<string, unknown>, options: ToolExecutionOptions) => Context
|
context: (args: Record<string, unknown>, options: ToolExecutionOptions) => Context
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
tools[MCP_RESOURCE_TOOLS.list] = tool({
|
const addListTool = (spec: {
|
||||||
|
id: string
|
||||||
|
description: string
|
||||||
|
serverDescription: string
|
||||||
|
title: string
|
||||||
|
resultKey: "resources" | "resourceTemplates"
|
||||||
|
sortKey: "uri" | "uriTemplate"
|
||||||
|
list: (server?: string) => Effect.Effect<Record<string, Record<string, unknown> & { client: string; name: string }>>
|
||||||
|
}) => {
|
||||||
|
tools[spec.id] = tool({
|
||||||
|
description: spec.description,
|
||||||
|
inputSchema: jsonSchema(
|
||||||
|
ProviderTransform.schema(deps.input.model, {
|
||||||
|
type: "object",
|
||||||
|
properties: { server: { type: "string", description: spec.serverDescription } },
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
execute(args, opts) {
|
||||||
|
return deps.run.promise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const server = optionalString(toRecord(args), "server")
|
||||||
|
const ctx = deps.context(toRecord(args), opts)
|
||||||
|
const resourceServers = Object.entries(yield* deps.mcp.clients())
|
||||||
|
.filter((entry) => !!entry[1].getServerCapabilities()?.resources)
|
||||||
|
.map((entry) => entry[0])
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
if (server && !resourceServers.includes(server)) {
|
||||||
|
throw new Error(
|
||||||
|
resourceServers.length === 0
|
||||||
|
? `MCP server "${server}" does not support resources`
|
||||||
|
: `MCP server "${server}" does not support resources. Available resource servers: ${resourceServers.join(", ")}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const permissionPatterns = server ? [`mcp:${server}:*`] : resourceServers.map((item) => `mcp:${item}:*`)
|
||||||
|
yield* deps.plugin.trigger(
|
||||||
|
"tool.execute.before",
|
||||||
|
{ tool: spec.id, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
||||||
|
{ args },
|
||||||
|
)
|
||||||
|
yield* ctx.ask({
|
||||||
|
permission: "read",
|
||||||
|
metadata: server ? { server } : {},
|
||||||
|
patterns: permissionPatterns,
|
||||||
|
always: permissionPatterns,
|
||||||
|
})
|
||||||
|
|
||||||
|
const filtered = Object.values(yield* spec.list(server))
|
||||||
|
.filter((item) => !server || item.client === server)
|
||||||
|
.toSorted((a, b) =>
|
||||||
|
(a.client + "\u0000" + a.name + "\u0000" + String(a[spec.sortKey] ?? "")).localeCompare(
|
||||||
|
b.client + "\u0000" + b.name + "\u0000" + String(b[spec.sortKey] ?? ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const truncated = yield* deps.truncate.output(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
[spec.resultKey]: filtered.map((item) => {
|
||||||
|
const result = Object.fromEntries(Object.entries(item).filter((entry) => entry[0] !== "client"))
|
||||||
|
return { ...result, server: item.client }
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
{},
|
||||||
|
deps.input.agent,
|
||||||
|
)
|
||||||
|
const output = {
|
||||||
|
title: server ? `${spec.title}: ${server}` : spec.title,
|
||||||
|
metadata: {
|
||||||
|
count: filtered.length,
|
||||||
|
servers: resourceServers,
|
||||||
|
...(server ? { server } : {}),
|
||||||
|
truncated: truncated.truncated,
|
||||||
|
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||||
|
},
|
||||||
|
output: truncated.content,
|
||||||
|
}
|
||||||
|
yield* deps.plugin.trigger(
|
||||||
|
"tool.execute.after",
|
||||||
|
{ tool: spec.id, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
if (opts.abortSignal?.aborted) yield* deps.input.processor.completeToolCall(opts.toolCallId, output)
|
||||||
|
return output
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
addListTool({
|
||||||
|
id: "list_mcp_resources",
|
||||||
description:
|
description:
|
||||||
"Lists resources provided by connected MCP servers. Resources provide context such as files, database schemas, or application-specific information.",
|
"Lists resources provided by connected MCP servers. Resources provide context such as files, database schemas, or application-specific information.",
|
||||||
inputSchema: jsonSchema(
|
serverDescription: "Optional MCP server name. When omitted, lists resources from every connected server.",
|
||||||
ProviderTransform.schema(deps.input.model, {
|
title: "MCP resources",
|
||||||
type: "object",
|
resultKey: "resources",
|
||||||
properties: {
|
sortKey: "uri",
|
||||||
server: {
|
list: deps.mcp.resources,
|
||||||
type: "string",
|
|
||||||
description: "Optional MCP server name. When omitted, lists resources from every connected server.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
additionalProperties: false,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
execute(args, opts) {
|
|
||||||
return deps.run.promise(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const parsed = parseListMcpResourcesArgs(args)
|
|
||||||
const ctx = deps.context(toRecord(args), opts)
|
|
||||||
const resourceServers = Object.entries(yield* deps.mcp.clients())
|
|
||||||
.filter((entry) => !!entry[1].getServerCapabilities()?.resources)
|
|
||||||
.map((entry) => entry[0])
|
|
||||||
.sort((a, b) => a.localeCompare(b))
|
|
||||||
if (parsed.server && !resourceServers.includes(parsed.server)) {
|
|
||||||
throw new Error(
|
|
||||||
resourceServers.length === 0
|
|
||||||
? `MCP server "${parsed.server}" does not support resources`
|
|
||||||
: `MCP server "${parsed.server}" does not support resources. Available resource servers: ${resourceServers.join(", ")}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const permissionPatterns = parsed.server
|
|
||||||
? [`mcp:${parsed.server}:*`]
|
|
||||||
: resourceServers.map((server) => `mcp:${server}:*`)
|
|
||||||
yield* deps.plugin.trigger(
|
|
||||||
"tool.execute.before",
|
|
||||||
{ tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
|
||||||
{ args },
|
|
||||||
)
|
|
||||||
yield* ctx.ask({
|
|
||||||
permission: "read",
|
|
||||||
metadata: parsed.server ? { server: parsed.server } : {},
|
|
||||||
patterns: permissionPatterns,
|
|
||||||
always: permissionPatterns,
|
|
||||||
})
|
|
||||||
|
|
||||||
const filtered = Object.values(yield* deps.mcp.resources(parsed.server))
|
|
||||||
.filter((resource) => !parsed.server || resource.client === parsed.server)
|
|
||||||
.toSorted((a, b) =>
|
|
||||||
(a.client + "\u0000" + a.name + "\u0000" + a.uri).localeCompare(
|
|
||||||
b.client + "\u0000" + b.name + "\u0000" + b.uri,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const truncated = yield* deps.truncate.output(
|
|
||||||
JSON.stringify({ resources: filtered.map(formatMcpResource) }, null, 2),
|
|
||||||
{},
|
|
||||||
deps.input.agent,
|
|
||||||
)
|
|
||||||
const output = {
|
|
||||||
title: parsed.server ? `MCP resources: ${parsed.server}` : "MCP resources",
|
|
||||||
metadata: {
|
|
||||||
count: filtered.length,
|
|
||||||
servers: resourceServers,
|
|
||||||
...(parsed.server ? { server: parsed.server } : {}),
|
|
||||||
truncated: truncated.truncated,
|
|
||||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
|
||||||
},
|
|
||||||
output: truncated.content,
|
|
||||||
}
|
|
||||||
yield* deps.plugin.trigger(
|
|
||||||
"tool.execute.after",
|
|
||||||
{ tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
|
||||||
output,
|
|
||||||
)
|
|
||||||
if (opts.abortSignal?.aborted) yield* deps.input.processor.completeToolCall(opts.toolCallId, output)
|
|
||||||
return output
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
addListTool({
|
||||||
tools[MCP_RESOURCE_TOOLS.listTemplates] = tool({
|
id: "list_mcp_resource_templates",
|
||||||
description:
|
description:
|
||||||
"Lists resource templates provided by connected MCP servers. Resource templates are parameterized resources that can be read after filling in their URI template.",
|
"Lists resource templates provided by connected MCP servers. Resource templates are parameterized resources that can be read after filling in their URI template.",
|
||||||
inputSchema: jsonSchema(
|
serverDescription: "Optional MCP server name. When omitted, lists resource templates from every connected server.",
|
||||||
ProviderTransform.schema(deps.input.model, {
|
title: "MCP resource templates",
|
||||||
type: "object",
|
resultKey: "resourceTemplates",
|
||||||
properties: {
|
sortKey: "uriTemplate",
|
||||||
server: {
|
list: deps.mcp.resourceTemplates,
|
||||||
type: "string",
|
|
||||||
description: "Optional MCP server name. When omitted, lists resource templates from every connected server.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
additionalProperties: false,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
execute(args, opts) {
|
|
||||||
return deps.run.promise(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const parsed = parseListMcpResourcesArgs(args)
|
|
||||||
const ctx = deps.context(toRecord(args), opts)
|
|
||||||
const resourceServers = Object.entries(yield* deps.mcp.clients())
|
|
||||||
.filter((entry) => !!entry[1].getServerCapabilities()?.resources)
|
|
||||||
.map((entry) => entry[0])
|
|
||||||
.sort((a, b) => a.localeCompare(b))
|
|
||||||
if (parsed.server && !resourceServers.includes(parsed.server)) {
|
|
||||||
throw new Error(
|
|
||||||
resourceServers.length === 0
|
|
||||||
? `MCP server "${parsed.server}" does not support resources`
|
|
||||||
: `MCP server "${parsed.server}" does not support resources. Available resource servers: ${resourceServers.join(", ")}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const permissionPatterns = parsed.server
|
|
||||||
? [`mcp:${parsed.server}:*`]
|
|
||||||
: resourceServers.map((server) => `mcp:${server}:*`)
|
|
||||||
yield* deps.plugin.trigger(
|
|
||||||
"tool.execute.before",
|
|
||||||
{ tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
|
||||||
{ args },
|
|
||||||
)
|
|
||||||
yield* ctx.ask({
|
|
||||||
permission: "read",
|
|
||||||
metadata: parsed.server ? { server: parsed.server } : {},
|
|
||||||
patterns: permissionPatterns,
|
|
||||||
always: permissionPatterns,
|
|
||||||
})
|
|
||||||
|
|
||||||
const filtered = Object.values(yield* deps.mcp.resourceTemplates(parsed.server))
|
|
||||||
.filter((template) => !parsed.server || template.client === parsed.server)
|
|
||||||
.toSorted((a, b) =>
|
|
||||||
(a.client + "\u0000" + a.name + "\u0000" + a.uriTemplate).localeCompare(
|
|
||||||
b.client + "\u0000" + b.name + "\u0000" + b.uriTemplate,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const truncated = yield* deps.truncate.output(
|
|
||||||
JSON.stringify({ resourceTemplates: filtered.map(formatMcpResourceTemplate) }, null, 2),
|
|
||||||
{},
|
|
||||||
deps.input.agent,
|
|
||||||
)
|
|
||||||
const output = {
|
|
||||||
title: parsed.server ? `MCP resource templates: ${parsed.server}` : "MCP resource templates",
|
|
||||||
metadata: {
|
|
||||||
count: filtered.length,
|
|
||||||
servers: resourceServers,
|
|
||||||
...(parsed.server ? { server: parsed.server } : {}),
|
|
||||||
truncated: truncated.truncated,
|
|
||||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
|
||||||
},
|
|
||||||
output: truncated.content,
|
|
||||||
}
|
|
||||||
yield* deps.plugin.trigger(
|
|
||||||
"tool.execute.after",
|
|
||||||
{ tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
|
||||||
output,
|
|
||||||
)
|
|
||||||
if (opts.abortSignal?.aborted) yield* deps.input.processor.completeToolCall(opts.toolCallId, output)
|
|
||||||
return output
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
tools[MCP_RESOURCE_TOOLS.read] = tool({
|
tools.read_mcp_resource = tool({
|
||||||
description:
|
description:
|
||||||
"Read a specific resource from an MCP server using the server name and resource URI. The URI is an MCP identifier and does not need to be a file URL.",
|
"Read a specific resource from an MCP server using the server name and resource URI. The URI is an MCP identifier and does not need to be a file URL.",
|
||||||
inputSchema: jsonSchema(
|
inputSchema: jsonSchema(
|
||||||
|
|
@ -486,42 +427,82 @@ function addResourceTools(
|
||||||
execute(args, opts) {
|
execute(args, opts) {
|
||||||
return deps.run.promise(
|
return deps.run.promise(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const parsed = parseReadMcpResourceArgs(args)
|
const argRecord = toRecord(args)
|
||||||
const ctx = deps.context(toRecord(args), opts)
|
const server = requiredString(argRecord, "server")
|
||||||
const client = (yield* deps.mcp.clients())[parsed.server]
|
const uri = requiredString(argRecord, "uri")
|
||||||
if (!client) throw new Error(`MCP server "${parsed.server}" is not connected`)
|
const ctx = deps.context(argRecord, opts)
|
||||||
if (!client.getServerCapabilities()?.resources) {
|
const client = (yield* deps.mcp.clients())[server]
|
||||||
throw new Error(`MCP server "${parsed.server}" does not support resources`)
|
if (!client) throw new Error(`MCP server "${server}" is not connected`)
|
||||||
}
|
if (!client.getServerCapabilities()?.resources)
|
||||||
|
throw new Error(`MCP server "${server}" does not support resources`)
|
||||||
yield* deps.plugin.trigger(
|
yield* deps.plugin.trigger(
|
||||||
"tool.execute.before",
|
"tool.execute.before",
|
||||||
{ tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
{ tool: "read_mcp_resource", sessionID: ctx.sessionID, callID: opts.toolCallId },
|
||||||
{ args },
|
{ args },
|
||||||
)
|
)
|
||||||
yield* ctx.ask({
|
yield* ctx.ask({
|
||||||
permission: "read",
|
permission: "read",
|
||||||
metadata: { server: parsed.server, uri: parsed.uri },
|
metadata: { server, uri },
|
||||||
patterns: [`mcp:${parsed.server}:${parsed.uri}`],
|
patterns: [`mcp:${server}:${uri}`],
|
||||||
always: [`mcp:${parsed.server}:*`],
|
always: [`mcp:${server}:*`],
|
||||||
})
|
})
|
||||||
|
|
||||||
const content = yield* deps.mcp.readResource(parsed.server, parsed.uri)
|
const content = yield* deps.mcp.readResource(server, uri)
|
||||||
if (!content) throw new Error(`Failed to read MCP resource: ${parsed.server}/${parsed.uri}`)
|
if (!content) throw new Error(`Failed to read MCP resource: ${server}/${uri}`)
|
||||||
|
|
||||||
const formatted = formatMcpResourceContent(parsed.server, parsed.uri, content)
|
const items = (Array.isArray(content.contents) ? content.contents : [content.contents]).filter(isRecord)
|
||||||
const truncated = yield* deps.truncate.output(formatted.text, {}, deps.input.agent)
|
const textParts: string[] = []
|
||||||
|
const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
const itemUri = typeof item.uri === "string" ? item.uri : uri
|
||||||
|
const mime = typeof item.mimeType === "string" ? item.mimeType : "application/octet-stream"
|
||||||
|
if ("text" in item && typeof item.text === "string") {
|
||||||
|
textParts.push(`Resource: ${itemUri}\nMIME: ${mime}\n${item.text}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!("blob" in item) || typeof item.blob !== "string") {
|
||||||
|
textParts.push(`[MCP resource content without text or blob: ${itemUri}]`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const size = base64Size(item.blob)
|
||||||
|
if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) {
|
||||||
|
textParts.push(
|
||||||
|
`[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (size > MAX_MCP_RESOURCE_BLOB_BYTES) {
|
||||||
|
textParts.push(
|
||||||
|
`[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
textParts.push(`[Binary MCP resource attached: ${itemUri} (${mime})]`)
|
||||||
|
attachments.push({
|
||||||
|
type: "file",
|
||||||
|
mime,
|
||||||
|
url: `data:${mime};base64,${item.blob}`,
|
||||||
|
filename: itemUri,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = yield* deps.truncate.output(
|
||||||
|
textParts.join("\n\n") || `MCP resource ${uri} from ${server} returned no contents.`,
|
||||||
|
{},
|
||||||
|
deps.input.agent,
|
||||||
|
)
|
||||||
const output = {
|
const output = {
|
||||||
title: `MCP resource: ${parsed.uri}`,
|
title: `MCP resource: ${uri}`,
|
||||||
metadata: {
|
metadata: {
|
||||||
server: parsed.server,
|
server,
|
||||||
uri: parsed.uri,
|
uri,
|
||||||
contents: formatted.contents,
|
contents: items.length,
|
||||||
attachments: formatted.attachments.length,
|
attachments: attachments.length,
|
||||||
truncated: truncated.truncated,
|
truncated: truncated.truncated,
|
||||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||||
},
|
},
|
||||||
output: truncated.content,
|
output: truncated.content,
|
||||||
attachments: formatted.attachments.map((attachment) => ({
|
attachments: attachments.map((attachment) => ({
|
||||||
...attachment,
|
...attachment,
|
||||||
id: PartID.ascending(),
|
id: PartID.ascending(),
|
||||||
sessionID: ctx.sessionID,
|
sessionID: ctx.sessionID,
|
||||||
|
|
@ -530,7 +511,7 @@ function addResourceTools(
|
||||||
}
|
}
|
||||||
yield* deps.plugin.trigger(
|
yield* deps.plugin.trigger(
|
||||||
"tool.execute.after",
|
"tool.execute.after",
|
||||||
{ tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
{ tool: "read_mcp_resource", sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
||||||
output,
|
output,
|
||||||
)
|
)
|
||||||
if (opts.abortSignal?.aborted) yield* deps.input.processor.completeToolCall(opts.toolCallId, output)
|
if (opts.abortSignal?.aborted) yield* deps.input.processor.completeToolCall(opts.toolCallId, output)
|
||||||
|
|
@ -583,14 +564,27 @@ function addDeferredTools(
|
||||||
execute(args, opts) {
|
execute(args, opts) {
|
||||||
return deps.run.promise(
|
return deps.run.promise(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const parsed = parseSearchDeferredToolsArgs(args)
|
const argRecord = toRecord(args)
|
||||||
const ctx = deps.context(toRecord(args), opts)
|
const query = optionalString(argRecord, "query") ?? ""
|
||||||
|
const limitValue = argRecord.limit
|
||||||
|
if (
|
||||||
|
limitValue !== undefined &&
|
||||||
|
limitValue !== null &&
|
||||||
|
(typeof limitValue !== "number" || !Number.isInteger(limitValue) || limitValue <= 0)
|
||||||
|
) {
|
||||||
|
throw new Error("limit must be a positive integer")
|
||||||
|
}
|
||||||
|
const limit =
|
||||||
|
limitValue === undefined || limitValue === null
|
||||||
|
? DEFAULT_DEFERRED_TOOL_LIMIT
|
||||||
|
: Math.min(limitValue, MAX_DEFERRED_TOOL_LIMIT)
|
||||||
|
const ctx = deps.context(argRecord, opts)
|
||||||
yield* deps.plugin.trigger(
|
yield* deps.plugin.trigger(
|
||||||
"tool.execute.before",
|
"tool.execute.before",
|
||||||
{ tool: DEFERRED_TOOL_TOOLS.search, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
{ tool: DEFERRED_TOOL_TOOLS.search, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
||||||
{ args },
|
{ args },
|
||||||
)
|
)
|
||||||
const matches = searchDeferredTools(deps.deferredDescriptors, parsed.query, parsed.limit)
|
const matches = searchDeferredTools(deps.deferredDescriptors, query, limit)
|
||||||
const truncated = yield* deps.truncate.output(
|
const truncated = yield* deps.truncate.output(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
|
|
@ -609,7 +603,7 @@ function addDeferredTools(
|
||||||
const output = {
|
const output = {
|
||||||
title: "Deferred tools search",
|
title: "Deferred tools search",
|
||||||
metadata: {
|
metadata: {
|
||||||
query: parsed.query,
|
query,
|
||||||
count: matches.length,
|
count: matches.length,
|
||||||
total: deps.deferredDescriptors.length,
|
total: deps.deferredDescriptors.length,
|
||||||
truncated: truncated.truncated,
|
truncated: truncated.truncated,
|
||||||
|
|
@ -653,15 +647,19 @@ function addDeferredTools(
|
||||||
execute(args, opts) {
|
execute(args, opts) {
|
||||||
return deps.run.promise(
|
return deps.run.promise(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const parsed = parseCallDeferredToolArgs(args)
|
const argRecord = toRecord(args)
|
||||||
const item = deps.allowedMcpTools[parsed.toolID]
|
const toolID = requiredString(argRecord, "tool_id")
|
||||||
|
const toolArgs = argRecord.arguments
|
||||||
|
if (toolArgs !== undefined && toolArgs !== null && !isRecord(toolArgs))
|
||||||
|
throw new Error("arguments must be an object")
|
||||||
|
const item = deps.allowedMcpTools[toolID]
|
||||||
const execute = item?.execute
|
const execute = item?.execute
|
||||||
if (!item || !execute) {
|
if (!item || !execute) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Deferred tool "${parsed.toolID}" is not available. Use search_deferred_tools and copy a tool_id from the results.`,
|
`Deferred tool "${toolID}" is not available. Use search_deferred_tools and copy a tool_id from the results.`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return yield* deps.executeMcpTool(parsed.toolID, execute, parsed.arguments, opts)
|
return yield* deps.executeMcpTool(toolID, execute, isRecord(toolArgs) ? toolArgs : {}, opts)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -673,32 +671,6 @@ function toRecord(value: unknown) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseListMcpResourcesArgs(value: unknown) {
|
|
||||||
const args = toRecord(value)
|
|
||||||
return { server: optionalString(args, "server") }
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseReadMcpResourceArgs(value: unknown) {
|
|
||||||
const args = toRecord(value)
|
|
||||||
return { server: requiredString(args, "server"), uri: requiredString(args, "uri") }
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSearchDeferredToolsArgs(value: unknown) {
|
|
||||||
const args = toRecord(value)
|
|
||||||
return {
|
|
||||||
query: optionalString(args, "query") ?? "",
|
|
||||||
limit: optionalPositiveInteger(args, "limit") ?? DEFAULT_DEFERRED_TOOL_LIMIT,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCallDeferredToolArgs(value: unknown) {
|
|
||||||
const args = toRecord(value)
|
|
||||||
return {
|
|
||||||
toolID: requiredString(args, "tool_id"),
|
|
||||||
arguments: optionalRecord(args, "arguments") ?? {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function optionalString(args: Record<string, unknown>, key: string) {
|
function optionalString(args: Record<string, unknown>, key: string) {
|
||||||
const value = args[key]
|
const value = args[key]
|
||||||
if (value === undefined || value === null || value === "") return undefined
|
if (value === undefined || value === null || value === "") return undefined
|
||||||
|
|
@ -712,22 +684,6 @@ function requiredString(args: Record<string, unknown>, key: string) {
|
||||||
throw new Error(`${key} is required`)
|
throw new Error(`${key} is required`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function optionalPositiveInteger(args: Record<string, unknown>, key: string) {
|
|
||||||
const value = args[key]
|
|
||||||
if (value === undefined || value === null) return undefined
|
|
||||||
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
||||||
throw new Error(`${key} must be a positive integer`)
|
|
||||||
}
|
|
||||||
return Math.min(value, MAX_DEFERRED_TOOL_LIMIT)
|
|
||||||
}
|
|
||||||
|
|
||||||
function optionalRecord(args: Record<string, unknown>, key: string) {
|
|
||||||
const value = args[key]
|
|
||||||
if (value === undefined || value === null) return undefined
|
|
||||||
if (isRecord(value)) return value
|
|
||||||
throw new Error(`${key} must be an object`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function deferredToolDescriptors(tools: Record<string, Tool>) {
|
function deferredToolDescriptors(tools: Record<string, Tool>) {
|
||||||
return Effect.forEach(
|
return Effect.forEach(
|
||||||
Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b)),
|
Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b)),
|
||||||
|
|
@ -738,15 +694,33 @@ function deferredToolDescriptors(tools: Record<string, Tool>) {
|
||||||
id,
|
id,
|
||||||
description: item.description ?? "",
|
description: item.description ?? "",
|
||||||
inputSchema: schema,
|
inputSchema: schema,
|
||||||
searchText: [id, item.description ?? "", ...schemaTerms(schema)].join("\n"),
|
searchText: [
|
||||||
|
id,
|
||||||
|
item.description ?? "",
|
||||||
|
...(isRecord(schema) && isRecord(schema.properties)
|
||||||
|
? Object.entries(schema.properties).flatMap(([name, property]) =>
|
||||||
|
isRecord(property) && typeof property.description === "string"
|
||||||
|
? [name, property.description]
|
||||||
|
: [name],
|
||||||
|
)
|
||||||
|
: []),
|
||||||
|
].join("\n"),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{ concurrency: "unbounded" },
|
{ concurrency: "unbounded" },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function deferredToolEstimatePayload(descriptor: DeferredToolDescriptor) {
|
function deferredToolSchemaTokens(descriptors: DeferredToolDescriptor[]) {
|
||||||
return { id: descriptor.id, description: descriptor.description, input_schema: descriptor.inputSchema }
|
return Token.estimate(
|
||||||
|
JSON.stringify(
|
||||||
|
descriptors.map((descriptor) => ({
|
||||||
|
id: descriptor.id,
|
||||||
|
description: descriptor.description,
|
||||||
|
input_schema: descriptor.inputSchema,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function deferredServerSummaries(toolIDs: string[], serverNames: string[]) {
|
function deferredServerSummaries(toolIDs: string[], serverNames: string[]) {
|
||||||
|
|
@ -765,44 +739,33 @@ function deferredServerSummaries(toolIDs: string[], serverNames: string[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function searchDeferredTools(descriptors: DeferredToolDescriptor[], query: string, limit: number) {
|
function searchDeferredTools(descriptors: DeferredToolDescriptor[], query: string, limit: number) {
|
||||||
const terms = searchTerms(query)
|
const terms = query
|
||||||
return descriptors
|
|
||||||
.map((descriptor) => ({ descriptor, score: scoreDeferredTool(descriptor, terms) }))
|
|
||||||
.filter((item) => terms.length === 0 || item.score > 0)
|
|
||||||
.toSorted((a, b) => b.score - a.score || a.descriptor.id.localeCompare(b.descriptor.id))
|
|
||||||
.slice(0, limit)
|
|
||||||
.map((item) => item.descriptor)
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoreDeferredTool(descriptor: DeferredToolDescriptor, terms: string[]) {
|
|
||||||
const id = descriptor.id.toLowerCase()
|
|
||||||
const description = descriptor.description.toLowerCase()
|
|
||||||
const searchText = descriptor.searchText.toLowerCase()
|
|
||||||
return terms.reduce(
|
|
||||||
(score, term) =>
|
|
||||||
score +
|
|
||||||
(id === term ? 20 : 0) +
|
|
||||||
(id.includes(term) ? 8 : 0) +
|
|
||||||
(description.includes(term) ? 4 : 0) +
|
|
||||||
(searchText.includes(term) ? 2 : 0),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchTerms(query: string) {
|
|
||||||
return query
|
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.split(/[^a-z0-9_-]+/)
|
.split(/[^a-z0-9_-]+/)
|
||||||
.map((term) => term.trim())
|
.map((term) => term.trim())
|
||||||
.filter((term) => term.length > 0 && term !== "*")
|
.filter((term) => term.length > 0 && term !== "*")
|
||||||
}
|
return descriptors
|
||||||
|
.map((descriptor) => {
|
||||||
function schemaTerms(schema: unknown) {
|
const id = descriptor.id.toLowerCase()
|
||||||
if (!isRecord(schema) || !isRecord(schema.properties)) return []
|
const description = descriptor.description.toLowerCase()
|
||||||
return Object.entries(schema.properties).flatMap(([name, property]) => {
|
const searchText = descriptor.searchText.toLowerCase()
|
||||||
if (!isRecord(property) || typeof property.description !== "string") return [name]
|
return {
|
||||||
return [name, property.description]
|
descriptor,
|
||||||
})
|
score: terms.reduce(
|
||||||
|
(score, term) =>
|
||||||
|
score +
|
||||||
|
(id === term ? 20 : 0) +
|
||||||
|
(id.includes(term) ? 8 : 0) +
|
||||||
|
(description.includes(term) ? 4 : 0) +
|
||||||
|
(searchText.includes(term) ? 2 : 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item) => terms.length === 0 || item.score > 0)
|
||||||
|
.toSorted((a, b) => b.score - a.score || a.descriptor.id.localeCompare(b.descriptor.id))
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((item) => item.descriptor)
|
||||||
}
|
}
|
||||||
|
|
||||||
function mcpToolContent(result: unknown): McpToolContent[] {
|
function mcpToolContent(result: unknown): McpToolContent[] {
|
||||||
|
|
@ -818,66 +781,6 @@ function mcpToolContent(result: unknown): McpToolContent[] {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function mcpToolMetadata(result: unknown) {
|
|
||||||
if (!isRecord(result) || !isRecord(result.metadata)) return {}
|
|
||||||
return result.metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMcpResource(resource: MCP.Resource) {
|
|
||||||
const result = Object.fromEntries(Object.entries(resource).filter((entry) => entry[0] !== "client"))
|
|
||||||
return { ...result, server: resource.client }
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMcpResourceTemplate(template: Record<string, unknown> & { client: string }) {
|
|
||||||
const result = Object.fromEntries(Object.entries(template).filter((entry) => entry[0] !== "client"))
|
|
||||||
return { ...result, server: template.client }
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMcpResourceContent(server: string, uri: string, content: { contents: unknown }) {
|
|
||||||
const items = (Array.isArray(content.contents) ? content.contents : [content.contents]).filter(isRecord)
|
|
||||||
const text: string[] = []
|
|
||||||
const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const itemUri = typeof item.uri === "string" ? item.uri : uri
|
|
||||||
const mime = typeof item.mimeType === "string" ? item.mimeType : "application/octet-stream"
|
|
||||||
if (typeof item.text === "string") {
|
|
||||||
text.push(`Resource: ${itemUri}\nMIME: ${mime}\n${item.text}`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (typeof item.blob === "string") {
|
|
||||||
const size = base64Size(item.blob)
|
|
||||||
if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) {
|
|
||||||
text.push(
|
|
||||||
`[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (size > MAX_MCP_RESOURCE_BLOB_BYTES) {
|
|
||||||
text.push(
|
|
||||||
`[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
text.push(`[Binary MCP resource attached: ${itemUri} (${mime})]`)
|
|
||||||
attachments.push({
|
|
||||||
type: "file",
|
|
||||||
mime,
|
|
||||||
url: `data:${mime};base64,${item.blob}`,
|
|
||||||
filename: itemUri,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
text.push(`[MCP resource content without text or blob: ${itemUri}]`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
contents: items.length,
|
|
||||||
attachments,
|
|
||||||
text: text.join("\n\n") || `MCP resource ${uri} from ${server} returned no contents.`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64Size(value: string) {
|
function base64Size(value: string) {
|
||||||
const trimmed = value.replace(/\s/g, "")
|
const trimmed = value.replace(/\s/g, "")
|
||||||
const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0
|
const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue