diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index 6d4b985dd..b09adb221 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -86,6 +86,7 @@ export function fetch( client: Client, list: (client: Client) => Promise, label: string, + key?: (item: T) => string, ) { return Effect.tryPromise({ try: () => list(client), @@ -100,7 +101,10 @@ export function fetch( Effect.map((items) => { const sanitizedClient = sanitize(clientName) return Object.fromEntries( - items.map((item) => [sanitizedClient + ":" + sanitize(item.name), { ...item, client: clientName }]), + items.map((item) => [ + key ? clientName + ":" + key(item) : sanitizedClient + ":" + sanitize(item.name), + { ...item, client: clientName }, + ]), ) }), Effect.orElseSucceed(() => undefined), diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 08d58118c..12df01799 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -161,7 +161,7 @@ export interface Interface { readonly clients: () => Effect.Effect> readonly tools: () => Effect.Effect> readonly prompts: () => Effect.Effect> - readonly resources: () => Effect.Effect> + readonly resources: (clientName?: string) => Effect.Effect> readonly add: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect<{ status: Record | Status }> readonly connect: (name: string) => Effect.Effect readonly disconnect: (name: string) => Effect.Effect @@ -654,17 +654,22 @@ export const layer = Layer.effect( s: State, listFn: (c: Client, timeout?: number) => Promise, label: string, + key?: (item: T) => string, + targetClientName?: string, ) { return Effect.gen(function* () { const cfg = yield* cfgSvc.get() return yield* Effect.forEach( - Object.entries(s.clients).filter(([name]) => s.status[name]?.status === "connected"), + Object.entries(s.clients).filter( + ([name]) => s.status[name]?.status === "connected" && (!targetClientName || name === targetClientName), + ), ([clientName, client]) => McpCatalog.fetch( clientName, client, (c) => listFn(c, requestTimeout(s, clientName, cfg.mcp?.[clientName], cfg.experimental?.mcp_timeout)), label, + key, ).pipe(Effect.map((items) => Object.entries(items ?? {}))), { concurrency: "unbounded" }, ).pipe(Effect.map((results) => Object.fromEntries(results.flat()))) @@ -675,8 +680,14 @@ export const layer = Layer.effect( return yield* collectFromConnected(yield* InstanceState.get(state), McpCatalog.prompts, "prompts") }) - const resources = Effect.fn("MCP.resources")(function* () { - return yield* collectFromConnected(yield* InstanceState.get(state), McpCatalog.resources, "resources") + const resources = Effect.fn("MCP.resources")(function* (clientName?: string) { + return yield* collectFromConnected( + yield* InstanceState.get(state), + McpCatalog.resources, + "resources", + (resource) => resource.uri, + clientName, + ) }) const withClient = Effect.fnUntraced(function* ( diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index cd1f935ad..e31956235 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -214,9 +214,10 @@ export function merge(...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule[] export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set { const edits = ["edit", "write", "apply_patch"] + const reads = ["list_mcp_resources", "read_mcp_resource"] return new Set( tools.filter((tool) => { - const permission = edits.includes(tool) ? "edit" : tool + const permission = edits.includes(tool) ? "edit" : reads.includes(tool) ? "read" : tool const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) return rule?.pattern === "*" && rule.action === "deny" }), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a1f4d95c3..92d371077 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -66,6 +66,14 @@ globalThis.AI_SDK_LOG_WARNINGS = false const decodeMessageInfo = Schema.decodeUnknownExit(SessionV1.Info) const decodeMessagePart = Schema.decodeUnknownExit(SessionV1.Part) +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", +]) const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format. @@ -77,6 +85,18 @@ IMPORTANT: const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.` +function mcpResourceBase64Size(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 formatMcpResourceBytes(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` +} + function isOrphanedInterruptedTool(part: SessionV1.ToolPart) { // cleanup() marks abandoned tool_use blocks this way after retries/aborts. // They are not pending work and must not trigger an assistant-prefill request. @@ -731,7 +751,8 @@ export const layer = Layer.effect( if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`) const items = Array.isArray(content.contents) ? content.contents : [content.contents] for (const c of items) { - if ("text" in c && c.text) { + if (!c || typeof c !== "object") continue + if ("text" in c && typeof c.text === "string" && c.text) { pieces.push({ messageID: info.id, sessionID: input.sessionID, @@ -739,18 +760,47 @@ export const layer = Layer.effect( synthetic: true, text: c.text, }) - } else if ("blob" in c && c.blob) { - const mime = "mimeType" in c ? c.mimeType : part.mime + } else if ("blob" in c && typeof c.blob === "string" && c.blob) { + const mime = "mimeType" in c && typeof c.mimeType === "string" ? c.mimeType : part.mime + const filename = "uri" in c && typeof c.uri === "string" ? c.uri : part.filename + const size = mcpResourceBase64Size(c.blob) + if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) { + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `[Binary MCP resource omitted: ${filename ?? uri} (${mime}, ${formatMcpResourceBytes(size)}) is not a supported attachment type]`, + }) + continue + } + if (size > MAX_MCP_RESOURCE_BLOB_BYTES) { + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `[Binary MCP resource omitted: ${filename ?? uri} (${mime}, ${formatMcpResourceBytes(size)}) exceeds ${formatMcpResourceBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`, + }) + continue + } pieces.push({ messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, - text: `[Binary content: ${mime}]`, + text: `[Binary MCP resource attached: ${filename ?? uri} (${mime})]`, + }) + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "file", + mime, + filename, + url: `data:${mime};base64,${c.blob}`, }) } } - pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) } else { const error = Cause.squash(exit.cause) yield* Effect.logError("failed to read MCP resource", { error, clientName, uri }) diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 87582ce07..371a6c9a3 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -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, 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, 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[] = [] + + 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" diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 34304624e..aa72ac032 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -361,7 +361,7 @@ it.instance( expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"]) expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"]) - expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:resource-one", "paged-server:resource-two"]) + expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:test://one", "paged-server:test://two"]) expect(serverState.listToolsCalls).toBe(2) expect(serverState.listPromptsCalls).toBe(2) expect(serverState.listResourcesCalls).toBe(2) @@ -796,7 +796,10 @@ it.instance( Effect.gen(function* () { lastCreatedClientName = "resource-server" const serverState = getOrCreateClientState("resource-server") - serverState.resources = [{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" }] + serverState.resources = [ + { name: "my-resource", uri: "file:///test.txt", description: "A test resource" }, + { name: "my-resource", uri: "ui://component-state", description: "A second resource with same name" }, + ] yield* mcp.add("resource-server", { type: "local", @@ -804,10 +807,10 @@ it.instance( }) const resources = yield* mcp.resources() - expect(Object.keys(resources).length).toBe(1) - const key = Object.keys(resources)[0] - expect(key).toContain("resource-server") - expect(key).toContain("my-resource") + expect(Object.keys(resources)).toEqual([ + "resource-server:file:///test.txt", + "resource-server:ui://component-state", + ]) }), ), { @@ -863,7 +866,7 @@ it.instance( expect(statusName(result.status, "resource-only-server")).toBe("connected") expect(serverState.listToolsCalls).toBe(0) expect(Object.keys(yield* mcp.tools())).toHaveLength(0) - expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only-server:docs"]) + expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only-server:docs://readme"]) expect(serverState.listResourcesCalls).toBe(1) expect(serverState.listPromptsCalls).toBe(0) }), diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 9b46853c7..e114082cc 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -269,7 +269,7 @@ mcpTest.instance( const result = yield* mcp.authenticate("test-oauth-resources") expect(result.status).toBe("connected") expect(listToolsCalls).toBe(0) - expect(Object.keys(yield* mcp.resources())).toEqual(["test-oauth-resources:docs"]) + expect(Object.keys(yield* mcp.resources())).toEqual(["test-oauth-resources:docs://readme"]) }), ), { config: config("test-oauth-resources") }, diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 2a16017a2..ffa6ae9f8 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -773,7 +773,7 @@ export function Autocomplete(props: { - {option().description} + {" " + option().description?.trimStart()}