Skip to content
39 changes: 39 additions & 0 deletions packages/opencode/src/mcp/content.ts
Original file line number Diff line number Diff line change
@@ -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"
7 changes: 6 additions & 1 deletion packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 10 additions & 22 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
94 changes: 94 additions & 0 deletions packages/opencode/src/tool/mcp-resource.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ListParameters>) =>
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<typeof ReadParameters>, 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<McpContent.Part, { type: "text" }> => part.type === "text")
.map((part) => part.text)
.join("\n\n")
const attachments = parts.filter(
(part): part is Extract<McpContent.Part, { type: "file" }> => 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 } : {}),
}
}),
}
}),
)
10 changes: 10 additions & 0 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<State>(
Expand Down Expand Up @@ -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 {
Expand All @@ -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] : []),
],
Expand Down Expand Up @@ -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)),
)
Expand Down Expand Up @@ -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"
29 changes: 29 additions & 0 deletions packages/opencode/test/mcp/content.test.ts
Original file line number Diff line number Diff line change
@@ -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]" },
])
})
})
37 changes: 37 additions & 0 deletions packages/opencode/test/session/message-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading