diff --git a/packages/opencode/src/mcp/content.ts b/packages/opencode/src/mcp/content.ts new file mode 100644 index 000000000000..5e2ba39e299d --- /dev/null +++ b/packages/opencode/src/mcp/content.ts @@ -0,0 +1,39 @@ +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { isImageAttachment } from "@/util/media" + +type ResourceContent = { + uri: string + mimeType?: string + text?: string + blob?: string +} + +export type Part = SessionV1.TextPartInput | SessionV1.FilePartInput + +export function toParts(contents: readonly ResourceContent[]): Part[] { + return contents.flatMap((content) => { + if (content.text) return [{ type: "text", text: content.text }] + if (!content.blob) return [] + + const mime = content.mimeType?.split(";", 1)[0]?.trim().toLowerCase() || "application/octet-stream" + if (isImageAttachment(mime)) { + return [ + { + type: "file", + mime, + filename: content.uri, + url: `data:${mime};base64,${content.blob}`, + }, + ] + } + + return [ + { + type: "text", + text: `[Binary resource: ${mime}, ${Buffer.from(content.blob, "base64").byteLength} bytes]`, + }, + ] + }) +} + +export * as McpContent from "./content" diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 1590e0890372..cb9e66304a99 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -220,7 +220,12 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( text: part.text, }) // text/plain and directory files are converted into text parts, ignore them - if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") { + if ( + part.type === "file" && + part.mime !== "text/plain" && + part.mime !== "application/x-directory" && + part.source?.type !== "resource" + ) { if (options?.stripMedia && isMedia(part.mime)) { userMessage.parts.push({ type: "text", diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c5047a6059e3..e4627cdff359 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -19,6 +19,7 @@ import { Plugin } from "../plugin" import MAX_STEPS from "../session/prompt/max-steps.txt" import { ToolRegistry } from "@/tool/registry" import { MCP } from "../mcp" +import { McpContent } from "@/mcp/content" import { LSP } from "@/lsp/lsp" import { ulid } from "ulid" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" @@ -729,27 +730,14 @@ export const layer = Layer.effect( if (Exit.isSuccess(exit)) { const content = exit.value 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) { - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: c.text, - }) - } else if ("blob" in c && c.blob) { - const mime = "mimeType" in c ? c.mimeType : part.mime - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `[Binary content: ${mime}]`, - }) - } - } + pieces.push( + ...McpContent.toParts(content.contents).map((item) => ({ + ...item, + messageID: info.id, + sessionID: input.sessionID, + ...(item.type === "text" ? { synthetic: true } : {}), + })), + ) pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) } else { const error = Cause.squash(exit.cause) @@ -992,7 +980,7 @@ export const layer = Layer.effect( ) const parts = yield* Effect.forEach(resolvedParts, (part) => - part.type === "file" && part.mime.startsWith("image/") + part.type === "file" && part.source?.type !== "resource" && part.mime.startsWith("image/") ? image.normalize(part).pipe( Effect.catchIf( (error) => error instanceof Image.ResizerUnavailableError, diff --git a/packages/opencode/src/tool/mcp-resource.ts b/packages/opencode/src/tool/mcp-resource.ts new file mode 100644 index 000000000000..3f14b9fc1f2f --- /dev/null +++ b/packages/opencode/src/tool/mcp-resource.ts @@ -0,0 +1,94 @@ +import { MCP } from "@/mcp" +import { McpContent } from "@/mcp/content" +import { Effect, Schema } from "effect" +import * as Tool from "./tool" + +export const ListParameters = Schema.Struct({ + clientName: Schema.optional(Schema.String).annotate({ + description: "Only list resources from this MCP server", + }), +}) + +export const ReadParameters = Schema.Struct({ + clientName: Schema.String.annotate({ + description: "The MCP server that provides the resource", + }), + uri: Schema.String.annotate({ + description: "The resource URI to read", + }), +}) + +export const ListMcpResourcesTool = Tool.define( + "list_mcp_resources", + Effect.gen(function* () { + const mcp = yield* MCP.Service + + return { + description: + "List resources exposed by connected MCP servers. Resources are read-only context such as files, schemas, documents, images, and application data. Use read_mcp_resource with the returned clientName and uri to retrieve one.", + parameters: ListParameters, + execute: (params: Schema.Schema.Type) => + Effect.gen(function* () { + const resources = Object.values(yield* mcp.resources()) + .filter((resource) => !params.clientName || resource.client === params.clientName) + .toSorted( + (a, b) => a.client.localeCompare(b.client) || a.name.localeCompare(b.name) || a.uri.localeCompare(b.uri), + ) + .map((resource) => ({ + clientName: resource.client, + name: resource.name, + uri: resource.uri, + ...(resource.description ? { description: resource.description } : {}), + ...(resource.mimeType ? { mimeType: resource.mimeType } : {}), + })) + + return { + title: "MCP resources", + metadata: { count: resources.length }, + output: resources.length ? JSON.stringify({ resources }, null, 2) : "No MCP resources found.", + } + }), + } + }), +) + +export const ReadMcpResourceTool = Tool.define( + "read_mcp_resource", + Effect.gen(function* () { + const mcp = yield* MCP.Service + + return { + description: + "Read a resource from a connected MCP server. Use list_mcp_resources to discover resources when needed. Text is returned directly, images are attached for inspection, and other binary data is described by MIME type and size.", + parameters: ReadParameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + yield* ctx.ask({ + permission: "read_mcp_resource", + patterns: [`${params.clientName}:${params.uri}`], + always: [`${params.clientName}:${params.uri}`], + metadata: { clientName: params.clientName, uri: params.uri }, + }) + + const result = yield* mcp.readResource(params.clientName, params.uri) + if (!result) throw new Error(`Failed to read MCP resource ${params.uri} from ${params.clientName}`) + + const parts = McpContent.toParts(result.contents) + const output = parts + .filter((part): part is Extract => part.type === "text") + .map((part) => part.text) + .join("\n\n") + const attachments = parts.filter( + (part): part is Extract => part.type === "file", + ) + + return { + title: `${params.clientName}: ${params.uri}`, + metadata: { clientName: params.clientName, uri: params.uri }, + output: output || `Read MCP resource ${params.uri}`, + ...(attachments.length ? { attachments } : {}), + } + }), + } + }), +) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 541d1f4bbbd0..fdcdb4fe444f 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -52,6 +52,8 @@ import { BackgroundJob } from "@/background/job" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { ListMcpResourcesTool, ReadMcpResourceTool } from "./mcp-resource" +import { MCP } from "@/mcp" export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) { return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel @@ -105,6 +107,8 @@ export const layer = Layer.effect( const greptool = yield* GrepTool const patchtool = yield* ApplyPatchTool const skilltool = yield* SkillTool + const listMcpResources = yield* ListMcpResourcesTool + const readMcpResource = yield* ReadMcpResourceTool const agent = yield* Agent.Service const state = yield* InstanceState.make( @@ -212,6 +216,8 @@ export const layer = Layer.effect( question: Tool.init(question), lsp: Tool.init(lsptool), plan: Tool.init(plan), + listMcpResources: Tool.init(listMcpResources), + readMcpResource: Tool.init(readMcpResource), }) return { @@ -231,6 +237,8 @@ export const layer = Layer.effect( tool.search, tool.skill, tool.patch, + tool.listMcpResources, + tool.readMcpResource, ...(flags.experimentalLspTool ? [tool.lsp] : []), ...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []), ], @@ -335,6 +343,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Format.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Truncate.defaultLayer), + Layer.provide(MCP.defaultLayer), ) .pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)), ) @@ -435,6 +444,7 @@ export const node = LayerNode.make(layer.pipe(Layer.provide(Ripgrep.defaultLayer Truncate.node, RuntimeFlags.node, Database.node, + MCP.node, ]) export * as ToolRegistry from "./registry" diff --git a/packages/opencode/test/mcp/content.test.ts b/packages/opencode/test/mcp/content.test.ts new file mode 100644 index 000000000000..eb0d56329981 --- /dev/null +++ b/packages/opencode/test/mcp/content.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { McpContent } from "@/mcp/content" + +describe("mcp.content", () => { + test("converts text, images, and other binary resources", () => { + expect( + McpContent.toParts([ + { uri: "fixture://guide", mimeType: "text/plain", text: "hello" }, + { uri: "fixture://picture", mimeType: "IMAGE/PNG; charset=binary", blob: "aGVsbG8=" }, + { uri: "fixture://archive", mimeType: "application/zip", blob: "aGVsbG8=" }, + ]), + ).toEqual([ + { type: "text", text: "hello" }, + { + type: "file", + mime: "image/png", + filename: "fixture://picture", + url: "data:image/png;base64,aGVsbG8=", + }, + { type: "text", text: "[Binary resource: application/zip, 5 bytes]" }, + ]) + }) + + test("does not treat SVG resources as model images", () => { + expect(McpContent.toParts([{ uri: "fixture://vector", mimeType: "image/svg+xml", blob: "PHN2Zy8+" }])).toEqual([ + { type: "text", text: "[Binary resource: image/svg+xml, 6 bytes]" }, + ]) + }) +}) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 1de84c9dd95b..620cf5499b29 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -319,6 +319,43 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + test("does not forward MCP resource references as file downloads", async () => { + const messageID = "m-user" + const input: SessionV1.WithParts[] = [ + { + info: userInfo(messageID), + parts: [ + { + ...basePart(messageID, "p1"), + type: "text", + text: "# Resource contents", + synthetic: true, + }, + { + ...basePart(messageID, "p2"), + type: "file", + mime: "application/json", + filename: "status", + url: "status://info", + source: { + type: "resource", + clientName: "resource-only-fixture", + uri: "status://info", + text: { value: "@status", start: 0, end: 7 }, + }, + }, + ] as SessionV1.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "# Resource contents" }], + }, + ]) + }) + test("converts assistant tool completion into tool-call + tool-result messages with attachments", async () => { const userID = "m-user" const assistantID = "m-assistant" diff --git a/packages/opencode/test/tool/mcp-resource.test.ts b/packages/opencode/test/tool/mcp-resource.test.ts new file mode 100644 index 000000000000..7c093091b668 --- /dev/null +++ b/packages/opencode/test/tool/mcp-resource.test.ts @@ -0,0 +1,107 @@ +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Agent } from "@/agent/agent" +import { MCP } from "@/mcp" +import { MessageID, SessionID } from "@/session/schema" +import { ListMcpResourcesTool, ReadMcpResourceTool } from "@/tool/mcp-resource" +import { Tool } from "@/tool/tool" +import { Truncate } from "@/tool/truncate" +import { testEffect } from "../lib/effect" + +const mcp = Layer.mock(MCP.Service, { + resources: () => + Effect.succeed({ + "zeta:guide": { + client: "zeta", + name: "guide", + uri: "fixture://guide", + description: "Guide", + mimeType: "text/plain", + }, + "alpha:picture": { + client: "alpha", + name: "picture", + uri: "fixture://picture", + mimeType: "image/png", + }, + }), + readResource: (clientName, uri) => + Effect.succeed( + clientName === "alpha" && uri === "fixture://picture" + ? { + contents: [ + { uri, mimeType: "text/plain", text: "caption" }, + { uri, mimeType: "image/png", blob: "aGVsbG8=" }, + ], + } + : undefined, + ), +}) + +const it = testEffect(Layer.mergeAll(mcp, Truncate.defaultLayer, Agent.defaultLayer)) + +const baseCtx: Omit = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, +} + +describe("tool.mcp-resource", () => { + it.instance("lists resources in a deterministic order", () => + Effect.gen(function* () { + const info = yield* ListMcpResourcesTool + const tool = yield* info.init() + const result = yield* tool.execute({}, { ...baseCtx, ask: () => Effect.void }) + + expect(JSON.parse(result.output)).toEqual({ + resources: [ + { clientName: "alpha", name: "picture", uri: "fixture://picture", mimeType: "image/png" }, + { + clientName: "zeta", + name: "guide", + uri: "fixture://guide", + description: "Guide", + mimeType: "text/plain", + }, + ], + }) + }), + ) + + it.instance("returns text and image content from a resource", () => + Effect.gen(function* () { + const requests: Array> = [] + const info = yield* ReadMcpResourceTool + const tool = yield* info.init() + const result = yield* tool.execute( + { clientName: "alpha", uri: "fixture://picture" }, + { + ...baseCtx, + ask: (request) => + Effect.sync(() => { + requests.push(request) + }), + }, + ) + + expect(result.output).toBe("caption") + expect(result.attachments).toEqual([ + { + type: "file", + mime: "image/png", + filename: "fixture://picture", + url: "data:image/png;base64,aGVsbG8=", + }, + ]) + expect(requests[0]).toMatchObject({ + permission: "read_mcp_resource", + patterns: ["alpha:fixture://picture"], + always: ["alpha:fixture://picture"], + }) + }), + ) +}) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 27447616fbff..5239cc46fc86 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -72,6 +72,8 @@ describe("tool.registry", () => { const ids = yield* registry.ids() expect(ids).not.toContain("task_status") + expect(ids).toContain("list_mcp_resources") + expect(ids).toContain("read_mcp_resource") }), )