fix(opencode): resolve MCP resource content

This commit is contained in:
Aiden Cline 2026-06-11 13:00:49 -05:00
parent e2527db3c7
commit 325f105009
4 changed files with 177 additions and 34 deletions

View file

@ -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",

View file

@ -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,

View file

@ -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"

View file

@ -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<typeof makeMcp> }
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",
() =>