feat(app): migrate MCP resources
This commit is contained in:
parent
988efb6217
commit
48e6118640
6 changed files with 234 additions and 44 deletions
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
47
packages/app/src/components/prompt-input/mcp-resource.ts
Normal file
47
packages/app/src/components/prompt-input/mcp-resource.ts
Normal 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(""))
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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] } }))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue