diff --git a/packages/app/src/components/prompt-input/mcp-resource.test.ts b/packages/app/src/components/prompt-input/mcp-resource.test.ts new file mode 100644 index 0000000000..573bdf13f7 --- /dev/null +++ b/packages/app/src/components/prompt-input/mcp-resource.test.ts @@ -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") + }) +}) diff --git a/packages/app/src/components/prompt-input/mcp-resource.ts b/packages/app/src/components/prompt-input/mcp-resource.ts new file mode 100644 index 0000000000..df7fc118aa --- /dev/null +++ b/packages/app/src/components/prompt-input/mcp-resource.ts @@ -0,0 +1,47 @@ +import type { McpResourceContent } from "@opencode-ai/sdk/v2/client" +import type { FileAttachmentPart, Prompt } from "@/context/prompt" + +type ResourceSource = Extract, { 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, +) { + return ( + await Promise.all( + prompt.map(async (part): Promise => { + 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("")) +} diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 05d978a947..0b00fca5e4 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -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") diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index 2811e6e3c6..0a8dc2f668 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -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] } })) diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 15d125df39..a504486dd0 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -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 + } } } diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 9b337aa076..c9b16a6301 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -67,7 +67,17 @@ export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: Opencod export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => queryOptions>({ 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)) + }, }) })