From 325f1050090ee606dc1b2d59fba0acc10c3568c4 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 11 Jun 2026 13:00:49 -0500 Subject: [PATCH] fix(opencode): resolve MCP resource content --- packages/opencode/src/session/message-v2.ts | 9 +- packages/opencode/src/session/prompt.ts | 22 ++- .../opencode/test/session/message-v2.test.ts | 37 +++++ packages/opencode/test/session/prompt.test.ts | 143 ++++++++++++++---- 4 files changed, 177 insertions(+), 34 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 1590e089037..951c6a7cfb0 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -219,8 +219,13 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( type: "text", 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") { + // Text, directories, and MCP references are resolved before provider conversion. + 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 c5047a6059e..70754ddf117 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -728,7 +728,16 @@ export const layer = Layer.effect( const exit = yield* mcp.readResource(clientName, uri).pipe(Effect.exit) if (Exit.isSuccess(exit)) { const content = exit.value - if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`) + if (!content) { + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Failed to read MCP resource ${part.filename}: resource not found or could not be read`, + }) + return pieces + } const items = Array.isArray(content.contents) ? content.contents : [content.contents] for (const c of items) { if ("text" in c && c.text) { @@ -740,13 +749,14 @@ export const layer = Layer.effect( text: c.text, }) } else if ("blob" in c && c.blob) { - const mime = "mimeType" in c ? c.mimeType : part.mime + const mime = ("mimeType" in c ? c.mimeType : undefined) ?? part.mime pieces.push({ messageID: info.id, sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `[Binary content: ${mime}]`, + type: "file", + mime, + filename: part.filename, + url: `data:${mime};base64,${c.blob}`, }) } } @@ -992,7 +1002,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/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 1de84c9dd95..2cbf55d5858 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 resolved 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: "text/markdown", + filename: "guide", + url: "opencode-fixture://guide", + source: { + type: "resource", + clientName: "resource-only-fixture", + uri: "opencode-fixture://guide", + text: { value: "@fixture-guide", start: 0, end: 14 }, + }, + }, + ] 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/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 12fa11869e6..25a0d7b81d5 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -108,28 +108,32 @@ function errorTool(parts: SessionV1.Part[]) { return part?.state.status === "error" ? (part as ErrorToolPart) : undefined } -const mcp = Layer.succeed( - MCP.Service, - MCP.Service.of({ - status: () => Effect.succeed({}), - clients: () => Effect.succeed({}), - tools: () => Effect.succeed({}), - prompts: () => Effect.succeed({}), - resources: () => Effect.succeed({}), - add: () => Effect.succeed({ status: { status: "disabled" as const } }), - connect: () => Effect.void, - disconnect: () => Effect.void, - getPrompt: () => Effect.succeed(undefined), - readResource: () => Effect.succeed(undefined), - startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), - authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"), - finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), - removeAuth: () => Effect.void, - supportsOAuth: () => Effect.succeed(false), - hasStoredTokens: () => Effect.succeed(false), - getAuthStatus: () => Effect.succeed("not_authenticated" as const), - }), -) +function makeMcp(readResource: MCP.Interface["readResource"] = () => Effect.succeed(undefined)) { + return Layer.succeed( + MCP.Service, + MCP.Service.of({ + status: () => Effect.succeed({}), + clients: () => Effect.succeed({}), + tools: () => Effect.succeed({}), + prompts: () => Effect.succeed({}), + resources: () => Effect.succeed({}), + add: () => Effect.succeed({ status: { status: "disabled" as const } }), + connect: () => Effect.void, + disconnect: () => Effect.void, + getPrompt: () => Effect.succeed(undefined), + readResource, + startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), + authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"), + finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), + removeAuth: () => Effect.void, + supportsOAuth: () => Effect.succeed(false), + hasStoredTokens: () => Effect.succeed(false), + getAuthStatus: () => Effect.succeed("not_authenticated" as const), + }), + ) +} + +const mcp = makeMcp() const lsp = Layer.succeed( LSP.Service, @@ -163,7 +167,9 @@ const blockingProcessor = Layer.succeed( }), ) -function makePrompt(input?: { processor?: "blocking" }) { +type PromptOptions = { processor?: "blocking"; mcp?: ReturnType } + +function makePrompt(input?: PromptOptions) { const deps = Layer.mergeAll( Session.defaultLayer, Snapshot.defaultLayer, @@ -176,7 +182,7 @@ function makePrompt(input?: { processor?: "blocking" }) { Config.defaultLayer, ProviderSvc.defaultLayer, lsp, - mcp, + input?.mcp ?? mcp, FSUtil.defaultLayer, BackgroundJob.defaultLayer, status, @@ -229,16 +235,40 @@ function makePrompt(input?: { processor?: "blocking" }) { ) } -function makeHttp(input?: { processor?: "blocking" }) { +function makeHttp(input?: PromptOptions) { return Layer.mergeAll(TestLLMServer.layer, makePrompt(input)) } -function makeHttpNoLLMServer(input?: { processor?: "blocking" }) { +function makeHttpNoLLMServer(input?: PromptOptions) { return makePrompt(input) } const it = testEffect(makeHttp()) const noLLMServer = testEffect(makeHttpNoLLMServer()) +const resourceNoLLMServer = testEffect( + makeHttpNoLLMServer({ + mcp: makeMcp((_clientName, uri) => + Effect.succeed({ + contents: + uri === "opencode-fixture://guide" + ? [ + { + uri, + mimeType: "text/markdown", + text: "# MCP resource fixture", + }, + ] + : [ + { + uri, + mimeType: "image/png", + blob: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + }, + ], + }), + ), + }), +) const raceNoLLMServer = testEffect(makeHttpNoLLMServer({ processor: "blocking" })) const unix = process.platform !== "win32" ? it.instance : it.instance.skip const unixNoLLMServer = process.platform !== "win32" ? noLLMServer.instance : noLLMServer.instance.skip @@ -2020,6 +2050,67 @@ noLLMServer.instance( { config: cfg }, ) +resourceNoLLMServer.instance( + "resolves MCP resource text and blobs without treating custom URIs as files", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + + const message = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [ + { + type: "file", + mime: "text/markdown", + url: "opencode-fixture://guide", + filename: "fixture-guide", + source: { + type: "resource", + clientName: "resource-only-fixture", + uri: "opencode-fixture://guide", + text: { value: "@fixture-guide", start: 0, end: 14 }, + }, + }, + { + type: "file", + mime: "image/png", + url: "opencode-fixture://pixel", + filename: "fixture-pixel", + source: { + type: "resource", + clientName: "resource-only-fixture", + uri: "opencode-fixture://pixel", + text: { value: "@fixture-pixel", start: 15, end: 29 }, + }, + }, + ], + }) + + expect(message.parts.some((part) => part.type === "text" && part.text === "# MCP resource fixture")).toBe(true) + expect( + message.parts.some( + (part) => part.type === "file" && part.source?.type === "resource" && part.url === "opencode-fixture://pixel", + ), + ).toBe(true) + expect( + message.parts.some( + (part) => + part.type === "file" && + !part.source && + part.mime === "image/png" && + part.url.startsWith("data:image/png;base64,"), + ), + ).toBe(true) + + yield* sessions.remove(session.id) + }), + { config: cfg }, +) + noLLMServer.instance( "keeps stored part order stable when file resolution is async", () =>