|
|
@@ -20,6 +20,18 @@ 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"
|
|
|
+
|
|
|
+const LIST_MCP_RESOURCES_TOOL = "list_mcp_resources"
|
|
|
+const READ_MCP_RESOURCE_TOOL = "read_mcp_resource"
|
|
|
+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
|
|
|
@@ -114,6 +126,175 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
|
|
})
|
|
|
}
|
|
|
|
|
|
+ const hasMcpResourceServer = Object.values(yield* mcp.clients()).some(
|
|
|
+ (client) => !!client.getServerCapabilities()?.resources,
|
|
|
+ )
|
|
|
+ if (hasMcpResourceServer) {
|
|
|
+ tools[LIST_MCP_RESOURCES_TOOL] = 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: LIST_MCP_RESOURCES_TOOL, 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: LIST_MCP_RESOURCES_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
|
|
+ output,
|
|
|
+ )
|
|
|
+ if (opts.abortSignal?.aborted) {
|
|
|
+ yield* input.processor.completeToolCall(opts.toolCallId, output)
|
|
|
+ }
|
|
|
+ return output
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ },
|
|
|
+ })
|
|
|
+
|
|
|
+ tools[READ_MCP_RESOURCE_TOOL] = 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: READ_MCP_RESOURCE_TOOL, 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: READ_MCP_RESOURCE_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
|
|
+ output,
|
|
|
+ )
|
|
|
+ if (opts.abortSignal?.aborted) {
|
|
|
+ yield* input.processor.completeToolCall(opts.toolCallId, output)
|
|
|
+ }
|
|
|
+ return output
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ },
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
for (const [key, item] of Object.entries(yield* mcp.tools())) {
|
|
|
const execute = item.execute
|
|
|
if (!execute) continue
|
|
|
@@ -163,10 +344,24 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
|
|
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: resource.mimeType ?? "application/octet-stream",
|
|
|
- url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
|
|
|
+ mime,
|
|
|
+ url: `data:${mime};base64,${resource.blob}`,
|
|
|
filename: resource.uri,
|
|
|
})
|
|
|
}
|
|
|
@@ -204,4 +399,94 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
|
|
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 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"
|