612 lines
24 KiB
TypeScript
612 lines
24 KiB
TypeScript
import { Agent } from "@/agent/agent"
|
|
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
|
import { Provider } from "@/provider/provider"
|
|
import { ProviderTransform } from "@/provider/transform"
|
|
import { MCP } from "@/mcp"
|
|
import { Permission } from "@/permission"
|
|
import { Tool } from "@/tool/tool"
|
|
import { ToolJsonSchema } from "@/tool/json-schema"
|
|
import { ToolRegistry } from "@/tool/registry"
|
|
import { Truncate } from "@/tool/truncate"
|
|
|
|
import { Plugin } from "@/plugin"
|
|
import type { TaskPromptOps } from "@/tool/task"
|
|
import { type Tool as AITool, tool, jsonSchema, type JSONSchema7, type ToolExecutionOptions, asSchema } from "ai"
|
|
import { Effect } from "effect"
|
|
import { MessageV2 } from "./message-v2"
|
|
import { Session } from "./session"
|
|
import { SessionProcessor } from "./processor"
|
|
import { PartID } from "./schema"
|
|
import { EffectBridge } from "@/effect/bridge"
|
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
|
import { ModelV2 } from "@opencode-ai/core/model"
|
|
import { isRecord } from "@/util/record"
|
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
|
|
|
const MCP_RESOURCE_TOOLS = {
|
|
list: "list_mcp_resources",
|
|
listTemplates: "list_mcp_resource_templates",
|
|
read: "read_mcp_resource",
|
|
} as const
|
|
const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024
|
|
const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([
|
|
"application/pdf",
|
|
"image/gif",
|
|
"image/jpeg",
|
|
"image/png",
|
|
"image/webp",
|
|
])
|
|
|
|
export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
|
agent: Agent.Info
|
|
model: Provider.Model
|
|
session: Session.Info
|
|
processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
|
|
bypassAgentCheck: boolean
|
|
messages: SessionV1.WithParts[]
|
|
promptOps: TaskPromptOps
|
|
}) {
|
|
const tools: Record<string, AITool> = {}
|
|
const run = yield* EffectBridge.make()
|
|
const plugin = yield* Plugin.Service
|
|
const permission = yield* Permission.Service
|
|
const registry = yield* ToolRegistry.Service
|
|
const mcp = yield* MCP.Service
|
|
const truncate = yield* Truncate.Service
|
|
|
|
const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({
|
|
sessionID: input.session.id,
|
|
abort: options.abortSignal!,
|
|
messageID: input.processor.message.id,
|
|
callID: options.toolCallId,
|
|
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },
|
|
agent: input.agent.name,
|
|
messages: input.messages,
|
|
metadata: (val) =>
|
|
input.processor.updateToolCall(options.toolCallId, (match) => {
|
|
if (!["running", "pending"].includes(match.state.status)) return match
|
|
return {
|
|
...match,
|
|
state: {
|
|
title: val.title,
|
|
metadata: val.metadata,
|
|
status: "running",
|
|
input: args,
|
|
time: { start: Date.now() },
|
|
},
|
|
}
|
|
}),
|
|
ask: (req) =>
|
|
permission
|
|
.ask({
|
|
...req,
|
|
sessionID: input.session.id,
|
|
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
|
|
ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
|
|
})
|
|
.pipe(Effect.orDie),
|
|
})
|
|
|
|
for (const item of yield* registry.tools({
|
|
modelID: ModelV2.ID.make(input.model.api.id),
|
|
providerID: input.model.providerID,
|
|
agent: input.agent,
|
|
})) {
|
|
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
|
|
tools[item.id] = tool({
|
|
description: item.description,
|
|
inputSchema: jsonSchema(schema),
|
|
execute(args, options) {
|
|
return run.promise(
|
|
Effect.gen(function* () {
|
|
const ctx = context(args, options)
|
|
yield* plugin.trigger(
|
|
"tool.execute.before",
|
|
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
|
|
{ args },
|
|
)
|
|
const result = yield* item.execute(args, ctx)
|
|
const output = {
|
|
...result,
|
|
attachments: result.attachments?.map((attachment) => ({
|
|
...attachment,
|
|
id: PartID.ascending(),
|
|
sessionID: ctx.sessionID,
|
|
messageID: input.processor.message.id,
|
|
})),
|
|
}
|
|
yield* plugin.trigger(
|
|
"tool.execute.after",
|
|
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
|
|
output,
|
|
)
|
|
if (options.abortSignal?.aborted) {
|
|
yield* input.processor.completeToolCall(options.toolCallId, output)
|
|
}
|
|
return output
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
}
|
|
|
|
const hasMcpResourceServer = Object.values(yield* mcp.clients()).some(
|
|
(client) => !!client.getServerCapabilities()?.resources,
|
|
)
|
|
if (hasMcpResourceServer) {
|
|
tools[MCP_RESOURCE_TOOLS.list] = tool({
|
|
description:
|
|
"Lists resources provided by connected MCP servers. Resources provide context such as files, database schemas, or application-specific information.",
|
|
inputSchema: jsonSchema(
|
|
ProviderTransform.schema(input.model, {
|
|
type: "object",
|
|
properties: {
|
|
server: {
|
|
type: "string",
|
|
description: "Optional MCP server name. When omitted, lists resources from every connected server.",
|
|
},
|
|
},
|
|
additionalProperties: false,
|
|
}),
|
|
),
|
|
execute(args, opts) {
|
|
return run.promise(
|
|
Effect.gen(function* () {
|
|
const parsed = parseListMcpResourcesArgs(args)
|
|
const ctx = context(toRecord(args), opts)
|
|
const clients = yield* mcp.clients()
|
|
const resourceServers = Object.entries(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* 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 resources = Object.values(yield* mcp.resources(parsed.server))
|
|
const filtered = resources
|
|
.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 content = JSON.stringify({ resources: filtered.map(formatMcpResource) }, null, 2)
|
|
const truncated = yield* truncate.output(content, {}, 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* plugin.trigger(
|
|
"tool.execute.after",
|
|
{ tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
|
output,
|
|
)
|
|
if (opts.abortSignal?.aborted) {
|
|
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
|
}
|
|
return output
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
|
|
tools[MCP_RESOURCE_TOOLS.listTemplates] = tool({
|
|
description:
|
|
"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(
|
|
ProviderTransform.schema(input.model, {
|
|
type: "object",
|
|
properties: {
|
|
server: {
|
|
type: "string",
|
|
description:
|
|
"Optional MCP server name. When omitted, lists resource templates from every connected server.",
|
|
},
|
|
},
|
|
additionalProperties: false,
|
|
}),
|
|
),
|
|
execute(args, opts) {
|
|
return run.promise(
|
|
Effect.gen(function* () {
|
|
const parsed = parseListMcpResourcesArgs(args)
|
|
const ctx = context(toRecord(args), opts)
|
|
const clients = yield* mcp.clients()
|
|
const resourceServers = Object.entries(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* 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 templates = Object.values(yield* mcp.resourceTemplates(parsed.server))
|
|
const filtered = templates
|
|
.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 content = JSON.stringify({ resourceTemplates: filtered.map(formatMcpResourceTemplate) }, null, 2)
|
|
const truncated = yield* truncate.output(content, {}, 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* plugin.trigger(
|
|
"tool.execute.after",
|
|
{ tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
|
output,
|
|
)
|
|
if (opts.abortSignal?.aborted) {
|
|
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
|
}
|
|
return output
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
|
|
tools[MCP_RESOURCE_TOOLS.read] = tool({
|
|
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.",
|
|
inputSchema: jsonSchema(
|
|
ProviderTransform.schema(input.model, {
|
|
type: "object",
|
|
properties: {
|
|
server: {
|
|
type: "string",
|
|
description: "MCP server name exactly as returned by list_mcp_resources.",
|
|
},
|
|
uri: {
|
|
type: "string",
|
|
description: "Resource URI to read. Use the exact URI string returned by list_mcp_resources.",
|
|
},
|
|
},
|
|
required: ["server", "uri"],
|
|
additionalProperties: false,
|
|
}),
|
|
),
|
|
execute(args, opts) {
|
|
return run.promise(
|
|
Effect.gen(function* () {
|
|
const parsed = parseReadMcpResourceArgs(args)
|
|
const ctx = context(toRecord(args), opts)
|
|
const clients = yield* mcp.clients()
|
|
const client = clients[parsed.server]
|
|
if (!client) {
|
|
throw new Error(`MCP server "${parsed.server}" is not connected`)
|
|
}
|
|
if (!client.getServerCapabilities()?.resources) {
|
|
throw new Error(`MCP server "${parsed.server}" does not support resources`)
|
|
}
|
|
yield* plugin.trigger(
|
|
"tool.execute.before",
|
|
{ tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
|
{ args },
|
|
)
|
|
yield* ctx.ask({
|
|
permission: "read",
|
|
metadata: { server: parsed.server, uri: parsed.uri },
|
|
patterns: [`mcp:${parsed.server}:${parsed.uri}`],
|
|
always: [`mcp:${parsed.server}:*`],
|
|
})
|
|
|
|
const content = yield* mcp.readResource(parsed.server, parsed.uri)
|
|
if (!content) throw new Error(`Failed to read MCP resource: ${parsed.server}/${parsed.uri}`)
|
|
|
|
const formatted = formatMcpResourceContent(parsed.server, parsed.uri, content)
|
|
const truncated = yield* truncate.output(formatted.text, {}, input.agent)
|
|
const output = {
|
|
title: `MCP resource: ${parsed.uri}`,
|
|
metadata: {
|
|
server: parsed.server,
|
|
uri: parsed.uri,
|
|
contents: formatted.contents,
|
|
attachments: formatted.attachments.length,
|
|
truncated: truncated.truncated,
|
|
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
|
},
|
|
output: truncated.content,
|
|
attachments: formatted.attachments.map((attachment) => ({
|
|
...attachment,
|
|
id: PartID.ascending(),
|
|
sessionID: ctx.sessionID,
|
|
messageID: input.processor.message.id,
|
|
})),
|
|
}
|
|
yield* plugin.trigger(
|
|
"tool.execute.after",
|
|
{ tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
|
output,
|
|
)
|
|
if (opts.abortSignal?.aborted) {
|
|
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
|
}
|
|
return output
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
}
|
|
|
|
const mcpTools: Record<string, AITool> = {}
|
|
const mcpSchemas: Record<string, JSONSchema7> = {}
|
|
for (const [key, item] of Object.entries(yield* mcp.tools())) {
|
|
const execute = item.execute
|
|
if (!execute) continue
|
|
|
|
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 ?? {} })
|
|
item.inputSchema = jsonSchema(transformed)
|
|
item.execute = (args, opts) =>
|
|
run.promise(
|
|
Effect.gen(function* () {
|
|
const ctx = context(args, opts)
|
|
yield* plugin.trigger(
|
|
"tool.execute.before",
|
|
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
|
{ args },
|
|
)
|
|
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {
|
|
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
|
|
return yield* Effect.promise(() => execute(args, opts))
|
|
}).pipe(
|
|
Effect.withSpan("Tool.execute", {
|
|
attributes: {
|
|
"tool.name": key,
|
|
"tool.call_id": opts.toolCallId,
|
|
"session.id": ctx.sessionID,
|
|
"message.id": input.processor.message.id,
|
|
},
|
|
}),
|
|
)
|
|
yield* plugin.trigger(
|
|
"tool.execute.after",
|
|
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
|
result,
|
|
)
|
|
|
|
const textParts: string[] = []
|
|
const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
|
|
for (const contentItem of result.content) {
|
|
if (contentItem.type === "text") textParts.push(contentItem.text)
|
|
else if (contentItem.type === "image") {
|
|
attachments.push({
|
|
type: "file",
|
|
mime: contentItem.mimeType,
|
|
url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
|
|
})
|
|
} else if (contentItem.type === "resource") {
|
|
const { resource } = contentItem
|
|
if (resource.text) textParts.push(resource.text)
|
|
if (resource.blob) {
|
|
const mime = resource.mimeType ?? "application/octet-stream"
|
|
const size = base64Size(resource.blob)
|
|
if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) {
|
|
textParts.push(
|
|
`[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`,
|
|
)
|
|
continue
|
|
}
|
|
if (size > MAX_MCP_RESOURCE_BLOB_BYTES) {
|
|
textParts.push(
|
|
`[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`,
|
|
)
|
|
continue
|
|
}
|
|
attachments.push({
|
|
type: "file",
|
|
mime,
|
|
url: `data:${mime};base64,${resource.blob}`,
|
|
filename: resource.uri,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent)
|
|
const metadata = {
|
|
...result.metadata,
|
|
truncated: truncated.truncated,
|
|
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
|
}
|
|
|
|
const output = {
|
|
title: "",
|
|
metadata,
|
|
output: truncated.content,
|
|
attachments: attachments.map((attachment) => ({
|
|
...attachment,
|
|
id: PartID.ascending(),
|
|
sessionID: ctx.sessionID,
|
|
messageID: input.processor.message.id,
|
|
})),
|
|
content: result.content,
|
|
}
|
|
if (opts.abortSignal?.aborted) {
|
|
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
|
}
|
|
return output
|
|
}),
|
|
)
|
|
mcpTools[key] = item
|
|
}
|
|
|
|
if (!Flag.OPENCODE_EXPERIMENTAL_MCP_TOOL_SEARCH) {
|
|
Object.assign(tools, mcpTools)
|
|
return tools
|
|
}
|
|
|
|
const { McpToolSearch } = yield* Effect.promise(() => import("@/mcp/tool-search"))
|
|
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 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),
|
|
})
|
|
const collision = Object.keys(controls).find((key) => tools[key])
|
|
if (collision) throw new Error(`Tool name reserved for MCP tool search: ${collision}`)
|
|
Object.assign(tools, controls)
|
|
return tools
|
|
})
|
|
|
|
function toRecord(value: unknown) {
|
|
if (isRecord(value)) return value
|
|
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 optionalString(args: Record<string, unknown>, key: string) {
|
|
const value = args[key]
|
|
if (value === undefined || value === null || value === "") return undefined
|
|
if (typeof value !== "string") throw new Error(`${key} must be a string`)
|
|
return value
|
|
}
|
|
|
|
function requiredString(args: Record<string, unknown>, key: string) {
|
|
const value = optionalString(args, key)
|
|
if (value) return value
|
|
throw new Error(`${key} is required`)
|
|
}
|
|
|
|
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) {
|
|
const trimmed = value.replace(/\s/g, "")
|
|
const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0
|
|
return Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding)
|
|
}
|
|
|
|
function formatBytes(value: number) {
|
|
if (value < 1024) return `${value} B`
|
|
if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB`
|
|
return `${Math.ceil(value / (1024 * 1024))} MB`
|
|
}
|
|
|
|
export * as SessionTools from "./tools"
|