feat(app): migrate MCP resources

This commit is contained in:
Aiden Cline 2026-07-07 00:06:12 -05:00
commit 48e6118640
6 changed files with 234 additions and 44 deletions

View file

@ -0,0 +1,79 @@
import { describe, expect, test } from "bun:test"
import { materializeMcpResources } from "./mcp-resource"
describe("MCP resource prompt parts", () => {
test("materializes text and blob content without retaining the resource source", async () => {
const prompt = await materializeMcpResources(
[
{
type: "file",
path: "docs://readme",
content: "@Readme",
start: 0,
end: 7,
filename: "Readme",
source: {
type: "resource",
clientName: "docs",
uri: "docs://readme",
text: { value: "@Readme", start: 0, end: 7 },
},
},
],
async () => ({
server: "docs",
uri: "docs://readme",
contents: [
{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
],
}),
)
expect(prompt).toEqual([
{
type: "file",
path: "docs://readme",
content: "@Readme",
start: 0,
end: 7,
mime: "text/plain",
filename: "Readme",
url: "data:text/plain;base64,aGVsbG8=",
},
{
type: "file",
path: "docs://logo",
content: "",
start: 0,
end: 0,
mime: "image/png",
filename: "Readme-2",
url: "data:image/png;base64,aGVsbG8=",
},
])
})
test("fails when a resource is unavailable", async () => {
await expect(
materializeMcpResources(
[
{
type: "file",
path: "docs://missing",
content: "@Missing",
start: 0,
end: 8,
source: {
type: "resource",
clientName: "docs",
uri: "docs://missing",
text: { value: "@Missing", start: 0, end: 8 },
},
},
],
async () => null,
),
).rejects.toThrow("Unable to read MCP resource: docs:docs://missing")
})
})

View file

@ -0,0 +1,47 @@
import type { McpResourceContent } from "@opencode-ai/sdk/v2/client"
import type { FileAttachmentPart, Prompt } from "@/context/prompt"
type ResourceSource = Extract<NonNullable<FileAttachmentPart["source"]>, { type: "resource" }>
export const hasMcpResources = (prompt: Prompt) =>
prompt.some((part) => part.type === "file" && part.source?.type === "resource")
export async function materializeMcpResources(
prompt: Prompt,
read: (source: ResourceSource) => Promise<McpResourceContent | null>,
) {
return (
await Promise.all(
prompt.map(async (part): Promise<Prompt> => {
if (part.type !== "file" || part.source?.type !== "resource") return [part]
const resource = await read(part.source)
if (!resource) throw new Error(`Unable to read MCP resource: ${part.source.clientName}:${part.source.uri}`)
if (resource.contents.length === 0)
throw new Error(`MCP resource returned no content: ${part.source.clientName}:${part.source.uri}`)
return resource.contents.map((content, index) => {
const mime =
content.mimeType ?? part.mime ?? (content.type === "text" ? "text/plain" : "application/octet-stream")
return {
type: "file",
path: content.uri,
content: index === 0 ? part.content : "",
start: index === 0 ? part.start : 0,
end: index === 0 ? part.end : 0,
mime,
filename: index === 0 ? part.filename : `${part.filename ?? "resource"}-${index + 1}`,
url: `data:${mime};base64,${content.type === "text" ? encodeText(content.text) : content.blob}`,
}
})
}),
)
).flat()
}
function encodeText(value: string) {
const bytes = new TextEncoder().encode(value)
const chunks: string[] = []
for (let index = 0; index < bytes.length; index += 0x8000) {
chunks.push(String.fromCharCode(...bytes.subarray(index, index + 0x8000)))
}
return btoa(chunks.join(""))
}

View file

@ -20,6 +20,7 @@ import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state"
import { hasMcpResources, materializeMcpResources } from "./mcp-resource"
type PendingPrompt = {
abort: AbortController
@ -54,7 +55,6 @@ const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttac
export async function sendFollowupDraft(input: FollowupSendInput) {
const text = draftText(input.draft.prompt)
const images = draftImages(input.draft.prompt)
const setBusy = () => {
if (!input.optimisticBusy) return
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" })
@ -71,6 +71,19 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return true
}
const materialize = () =>
materializeMcpResources(input.draft.prompt, async (source) => {
const response = await input.client.v2.mcp.resource.read(
{
location: { directory: input.draft.sessionDirectory },
server: source.clientName,
uri: source.uri,
},
{ throwOnError: true },
)
return response.data.data
})
const [head, ...tail] = text.split(" ")
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
@ -81,6 +94,9 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return false
}
const prompt = hasMcpResources(input.draft.prompt) ? await materialize() : input.draft.prompt
const images = draftImages(prompt)
await input.client.session.command({
sessionID: input.draft.sessionID,
command: cmd,
@ -88,13 +104,28 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
agent: input.draft.agent,
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
variant: input.draft.variant,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
})),
parts: [
...images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
})),
...prompt.flatMap((part) =>
part.type === "file" && part.url?.startsWith("data:")
? [
{
id: Identifier.ascending("part"),
type: "file" as const,
mime: part.mime ?? "text/plain",
url: part.url ?? part.path,
filename: part.filename,
},
]
: [],
),
],
})
return true
} catch (err) {
@ -103,9 +134,26 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
}
}
let ready = false
let prompt = input.draft.prompt
if (hasMcpResources(prompt)) {
setBusy()
if (!(await wait())) {
setIdle()
return false
}
try {
prompt = await materialize()
} catch (error) {
setIdle()
throw error
}
ready = true
}
const images = draftImages(prompt)
const messageID = input.messageID ?? Identifier.ascending("message")
const { requestParts, optimisticParts } = buildRequestParts({
prompt: input.draft.prompt,
prompt,
context: input.draft.context,
images,
text,
@ -144,7 +192,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
})
try {
if (!(await wait())) {
if (!ready && !(await wait())) {
batch(() => {
setIdle()
remove()
@ -457,39 +505,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return
}
if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1)
const customCommand = sync().data.command.find((c) => c.name === commandName)
if (customCommand) {
clearInput()
client.session
.command({
sessionID: session.id,
command: commandName,
arguments: args.join(" "),
agent,
model: `${model.providerID}/${model.modelID}`,
variant,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
})),
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")),
})
restoreInput()
})
return
}
}
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
const messageID = Identifier.ascending("message")

View file

@ -134,6 +134,28 @@ describe("applyGlobalEvent", () => {
})
describe("applyDirectoryEvent", () => {
test("refreshes MCP state after MCP events", () => {
const [store, setStore] = createStore(baseState())
const calls: string[] = []
const apply = (type: "mcp.status.changed" | "mcp.resources.changed") =>
applyDirectoryEvent({
event: { type, properties: { server: "docs" } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
loadMcp: () => calls.push("status"),
loadMcpResources: () => calls.push("resources"),
})
apply("mcp.resources.changed")
expect(calls).toEqual(["resources"])
calls.length = 0
apply("mcp.status.changed")
expect(calls).toEqual(["status", "resources"])
})
test("initializes text delta accumulation from the current part text", () => {
const part = { ...textPart("part", "session", "message"), text: "existing" }
const [store, setStore] = createStore(baseState({ part: { message: [part] } }))

View file

@ -113,6 +113,8 @@ export function applyDirectoryEvent(input: {
directory: string
loadLsp: () => void
loadReferences?: () => void
loadMcp?: () => void
loadMcpResources?: () => void
vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
retainedLimit?: number
@ -409,5 +411,14 @@ export function applyDirectoryEvent(input: {
input.loadReferences?.()
break
}
case "mcp.status.changed": {
input.loadMcp?.()
input.loadMcpResources?.()
break
}
case "mcp.resources.changed": {
input.loadMcpResources?.()
break
}
}
}

View file

@ -67,7 +67,17 @@ export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: Opencod
export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions<Record<string, McpResource>>({
queryKey: [scope, directory, "mcpResources"] as const,
queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}),
queryFn: () =>
sdk.v2.mcp.resource
.catalog({ location: { directory } }, { throwOnError: true })
.then((response) =>
Object.fromEntries(
response.data.data.resources.map((resource) => [
`${encodeURIComponent(resource.server)}:${resource.uri}`,
{ ...resource, client: resource.server },
]),
),
),
placeholderData: {},
})
@ -416,6 +426,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
loadReferences: () => {
void queryClient.fetchQuery(queryOptionsApi.references(key))
},
loadMcp: () => {
void queryClient.refetchQueries(queryOptionsApi.mcp(key))
},
loadMcpResources: () => {
void queryClient.refetchQueries(queryOptionsApi.mcpResources(key))
},
})
})