diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 458f9c2d2e..a072679c02 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -310,6 +310,10 @@ import type { V2LocationGetResponses, V2McpListErrors, V2McpListResponses, + V2McpResourceCatalogErrors, + V2McpResourceCatalogResponses, + V2McpResourceReadErrors, + V2McpResourceReadResponses, V2ModelDefaultErrors, V2ModelDefaultResponses, V2ModelListErrors, @@ -6969,6 +6973,74 @@ export class Integration extends HeyApiClient { } } +export class Resource2 extends HeyApiClient { + /** + * List MCP resources + * + * Retrieve resources and resource templates from connected MCP servers. + */ + public catalog( + parameters?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get< + V2McpResourceCatalogResponses, + V2McpResourceCatalogErrors, + ThrowOnError + >({ + url: "/api/mcp/resource", + ...options, + ...params, + }) + } + + /** + * Read MCP resource + * + * Read the current content of one resource from a connected MCP server. + */ + public read( + parameters?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + server?: string + uri?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { in: "body", key: "server" }, + { in: "body", key: "uri" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/mcp/resource/read", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + export class Mcp2 extends HeyApiClient { /** * List MCP servers @@ -6991,6 +7063,11 @@ export class Mcp2 extends HeyApiClient { ...params, }) } + + private _resource?: Resource2 + get resource(): Resource2 { + return (this._resource ??= new Resource2({ client: this.client })) + } } export class Credential extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 87aa5ddbe1..ed81055de4 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -95,6 +95,7 @@ export type Event = | EventTuiToastShow2 | EventTuiSessionSelect2 | EventMcpToolsChanged + | EventMcpResourcesChanged | EventMcpStatusChanged | EventCommandExecuted | EventFileEdited @@ -1603,6 +1604,13 @@ export type GlobalEvent = { server: string } } + | { + id: string + type: "mcp.resources.changed" + properties: { + server: string + } + } | { id: string type: "mcp.status.changed" @@ -2991,6 +2999,20 @@ export type ProviderNotFoundError = { message: string } +export type McpResource2 = { + server: string + name: string + uri: string + description?: string + mimeType?: string +} + +export type McpServerNotFoundError1 = { + _tag: "McpServerNotFoundError" + server: string + message: string +} + export type FormNotFoundError = { _tag: "FormNotFoundError" id: string @@ -3153,6 +3175,7 @@ export type V2Event = | TuiToastShow | TuiSessionSelect | McpToolsChanged + | McpResourcesChanged | McpStatusChanged | CommandExecuted | FileEdited @@ -5702,6 +5725,39 @@ export type McpServer = { integrationID?: string } +export type McpResourceTemplate = { + server: string + name: string + uriTemplate: string + description?: string + mimeType?: string +} + +export type McpResourceCatalog = { + resources: Array + templates: Array +} + +export type McpResourceContentPart = + | { + type: "text" + uri: string + text: string + mimeType?: string + } + | { + type: "blob" + uri: string + blob: string + mimeType?: string + } + +export type McpResourceContent = { + server: string + uri: string + contents: Array +} + export type ProjectCurrent = { id: string directory: string @@ -6608,6 +6664,19 @@ export type McpToolsChanged = { } } +export type McpResourcesChanged = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "mcp.resources.changed" + location?: LocationRef + data: { + server: string + } +} + export type McpStatusChanged = { id: string created: number @@ -7760,6 +7829,14 @@ export type EventMcpToolsChanged = { } } +export type EventMcpResourcesChanged = { + id: string + type: "mcp.resources.changed" + properties: { + server: string + } +} + export type EventMcpStatusChanged = { id: string type: "mcp.status.changed" @@ -8034,6 +8111,12 @@ export type SessionMessagesResponseV2 = { } } +export type McpServerNotFoundErrorV2 = { + _tag: "McpServerNotFoundError" + server: string + message: string +} + export type SessionV2 = { id: string slug: string @@ -8597,6 +8680,7 @@ export type V2EventV2 = | InstallationUpdateAvailableV2 | VcsBranchUpdatedV2 | McpStatusChangedV2 + | McpResourcesChangedV2 | PermissionAskedV2 | PermissionRepliedV2 | QuestionAskedV2 @@ -9686,6 +9770,19 @@ export type EventLogSyncedV2 = { seq?: number } +export type McpResourceV2 = { + server: string + name: string + uri: string + description?: string + mimeType?: string +} + +export type McpResourceCatalogV2 = { + resources: Array + templates: Array +} + export type ProjectTimeV2 = { created: number updated: number @@ -10615,6 +10712,19 @@ export type McpStatusChangedV2 = { } } +export type McpResourcesChangedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "mcp.resources.changed" + location?: LocationRefV2 + data: { + server: string + } +} + export type PermissionAskedV2 = { id: string created: number @@ -16685,6 +16795,87 @@ export type V2McpListResponses = { export type V2McpListResponse = V2McpListResponses[keyof V2McpListResponses] +export type V2McpResourceCatalogData = { + body?: never + path?: never + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } + url: "/api/mcp/resource" +} + +export type V2McpResourceCatalogErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2McpResourceCatalogError = V2McpResourceCatalogErrors[keyof V2McpResourceCatalogErrors] + +export type V2McpResourceCatalogResponses = { + /** + * Success + */ + 200: { + location: LocationInfoV2 + data: McpResourceCatalogV2 + } +} + +export type V2McpResourceCatalogResponse = V2McpResourceCatalogResponses[keyof V2McpResourceCatalogResponses] + +export type V2McpResourceReadData = { + body: { + server: string + uri: string + } + path?: never + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } + url: "/api/mcp/resource/read" +} + +export type V2McpResourceReadErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * McpServerNotFoundError + */ + 404: McpServerNotFoundErrorV2 +} + +export type V2McpResourceReadError = V2McpResourceReadErrors[keyof V2McpResourceReadErrors] + +export type V2McpResourceReadResponses = { + /** + * Success + */ + 200: { + location: LocationInfoV2 + data: McpResourceContent | null + } +} + +export type V2McpResourceReadResponse = V2McpResourceReadResponses[keyof V2McpResourceReadResponses] + export type V2CredentialRemoveData = { body?: never path: { diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 65bff40838..a0e0acd54d 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -207,7 +207,13 @@ export function Autocomplete(props: { props.setPrompt((draft) => { if (part.type === "file") { const files = (draft.files ??= []) - const existingIndex = files.findIndex((file) => file.uri === part.value.uri) + const existingIndex = files.findIndex( + (file) => + file.uri === part.value.uri && + file.mcp?.server === part.value.mcp?.server && + file.mcp?.location.directory === part.value.mcp?.location.directory && + file.mcp?.location.workspaceID === part.value.mcp?.location.workspaceID, + ) if (existingIndex !== -1) { const existing = files[existingIndex] if (existing?.mention) { @@ -215,6 +221,7 @@ export function Autocomplete(props: { existing.mention.end = extmarkEnd existing.mention.text = virtualText } + props.setExtmark({ type: "file", index: existingIndex }, extmarkId) return } if (part.value.mention) { @@ -363,7 +370,7 @@ export function Autocomplete(props: { const options: AutocompleteOption[] = [] const width = props.anchor().width - 4 - for (const res of Object.values(sync.data.mcp_resource)) { + for (const res of data.location.mcp.resource.catalog(location())?.resources ?? []) { options.push({ display: Locale.truncateMiddle(res.name, width), // Match the name only; matching the URI caused unrelated fuzzy hits. @@ -377,6 +384,7 @@ export function Autocomplete(props: { name: res.name, description: res.description, mention: { start: 0, end: 0, text: "" }, + mcp: { server: res.server, uri: res.uri, location: location() ?? data.location.default() }, }, }) }, diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 7aac079b03..801f0de316 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -31,6 +31,7 @@ import { useExit } from "../../context/exit" import { promptOffsetWidth } from "../../prompt/display" import { createStore, produce, unwrap } from "solid-js/store" import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history" +import { materializeMcpResources } from "./mcp-resource" import { computePromptTraits } from "../../prompt/traits" import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part" import { usePromptStash } from "../../prompt/stash" @@ -164,9 +165,9 @@ export function Prompt(props: PromptProps) { const status = createMemo(() => data.session.status(props.sessionID ?? "")) const activeSubagents = createMemo(() => { if (!props.sessionID) return 0 - return data.session.family(props.sessionID).filter( - (id) => id !== props.sessionID && data.session.status(id) === "running", - ).length + return data.session + .family(props.sessionID) + .filter((id) => id !== props.sessionID && data.session.status(id) === "running").length }) const runningShells = createMemo( () => data.shell.list(currentLocation()).filter((shell) => shell.metadata.sessionID === props.sessionID).length, @@ -984,6 +985,36 @@ export function Prompt(props: PromptProps) { } const variant = local.model.variant.current() + const inputText = expandTrackedPastedText( + store.prompt.text, + input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => { + const ref = store.extmarkToPart.get(extmark.id) + if (ref?.type !== "pasted") return [] + const part = store.prompt.pasted[ref.index] + if (!part) return [] + return [{ start: extmark.start, end: extmark.end, text: part.text }] + }), + ) + const commandName = inputText.split("\n")[0].split(" ")[0].slice(1) + const isCommand = + inputText.startsWith("/") && + (data.location.command.list(currentLocation()) ?? []).some((command) => command.name === commandName) + const isSkill = + inputText.startsWith("/") && + (data.location.skill.list(currentLocation()) ?? []).some( + (skill) => skill.slash === true && skill.name === commandName, + ) + const files = + store.mode === "normal" && !isSkill + ? await materializeMcpResources(store.prompt.files, (resource) => + data.location.mcp.resource.read(resource), + ).catch((error) => { + toast.show({ title: "Failed to read MCP resource", message: errorMessage(error), variant: "error" }) + return undefined + }) + : [] + if (!files) return false + let sessionID = props.sessionID let session = sessionID ? data.session.get(sessionID) : undefined let finishMoveProgress = false @@ -1025,17 +1056,6 @@ export function Prompt(props: PromptProps) { session = structuredClone(created) as SessionV2Info } - const inputText = expandTrackedPastedText( - store.prompt.text, - input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => { - const ref = store.extmarkToPart.get(extmark.id) - if (ref?.type !== "pasted") return [] - const part = store.prompt.pasted[ref.index] - if (!part) return [] - return [{ start: extmark.start, end: extmark.end, text: part.text }] - }), - ) - // Capture mode before it gets reset const currentMode = store.mode const editorSelection = editorContext() @@ -1063,12 +1083,7 @@ export function Prompt(props: PromptProps) { command: inputText, }) setStore("mode", "normal") - } else if ( - inputText.startsWith("/") && - (data.location.command.list(currentLocation()) ?? []).some( - (command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1), - ) - ) { + } else if (isCommand) { move.startSubmit() // Parse command from first line, preserve multi-line content in arguments const firstLineEnd = inputText.indexOf("\n") @@ -1077,25 +1092,25 @@ export function Prompt(props: PromptProps) { const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1) const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") - void sdk.api.session + const error = await sdk.api.session .command({ sessionID, command: command.slice(1), arguments: args, agent: agent.id, model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }, - files: store.prompt.files, + files, agents: store.prompt.agents, }) - .catch((error) => { - toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" }) - }) - } else if ( - inputText.startsWith("/") && - (data.location.skill.list(currentLocation()) ?? []).some( - (skill) => skill.slash === true && skill.name === inputText.split("\n")[0].split(" ")[0].slice(1), - ) - ) { + .then( + () => undefined, + (error) => error, + ) + if (error) { + toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" }) + return false + } + } else if (isSkill) { move.startSubmit() void sdk.api.session.skill({ sessionID, @@ -1135,7 +1150,7 @@ export function Prompt(props: PromptProps) { sessionID, prompt: { text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"), - files: store.prompt.files, + files, agents: store.prompt.agents, }, }) diff --git a/packages/tui/src/component/prompt/mcp-resource.ts b/packages/tui/src/component/prompt/mcp-resource.ts new file mode 100644 index 0000000000..d96ac65df8 --- /dev/null +++ b/packages/tui/src/component/prompt/mcp-resource.ts @@ -0,0 +1,32 @@ +import { Buffer } from "node:buffer" +import type { ServerMcpReadResourceOutput, SessionPromptInput } from "@opencode-ai/client/promise" +import type { LocationRef } from "@opencode-ai/sdk/v2" +import type { PromptFile } from "../../prompt/history" + +type Files = NonNullable +type ResourceContent = NonNullable + +export async function materializeMcpResources( + files: PromptFile[] | undefined, + read: (input: { server: string; uri: string; location: LocationRef }) => Promise, +): Promise { + return ( + await Promise.all( + (files ?? []).map(async (file): Promise => { + if (!file.mcp) return [{ uri: file.uri, name: file.name, description: file.description, mention: file.mention }] + const resource = await read(file.mcp) + if (!resource) throw new Error(`Unable to read MCP resource: ${file.mcp.server}:${file.mcp.uri}`) + if (resource.contents.length === 0) + throw new Error(`MCP resource returned no content: ${file.mcp.server}:${file.mcp.uri}`) + return resource.contents.map((content, index) => ({ + uri: `data:${content.mimeType ?? (content.type === "text" ? "text/plain" : "application/octet-stream")};base64,${ + content.type === "text" ? Buffer.from(content.text).toString("base64") : content.blob + }`, + name: index === 0 ? file.name : `${file.name ?? "resource"}-${index + 1}`, + description: file.description, + mention: index === 0 ? file.mention : undefined, + })) + }), + ) + ).flat() +} diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 723f80633d..7fdf3925c7 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -21,6 +21,7 @@ import type { SkillV2Info, V2Event, } from "@opencode-ai/sdk/v2" +import type { ServerMcpResourceCatalogOutput, ServerMcpReadResourceOutput } from "@opencode-ai/client/promise" import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" @@ -37,6 +38,7 @@ type LocationData = { command?: CommandV2Info[] integration?: IntegrationInfo[] mcp?: McpServer[] + mcpResource?: ServerMcpResourceCatalogOutput["data"] model?: ModelV2Info[] provider?: ProviderV2Info[] reference?: ReferenceInfo[] @@ -113,6 +115,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ let connectionGeneration = 0 let statusChanges: Set | undefined let bootstrapping: Promise | undefined + const pendingMcpRefresh = new Map() function setSessionStatus(sessionID: string, status: DataSessionStatus) { statusChanges?.add(sessionID) @@ -754,8 +757,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ // Authenticating an MCP integration reconnects its server, which emits mcp.status.changed, // so the mcp list refreshes here rather than off integration.updated. case "mcp.status.changed": - if (bootstrapping) break - void result.location.mcp.refresh(event.location) + if (bootstrapping) { + const location = event.location ?? defaultLocation() + pendingMcpRefresh.set(locationKey(location), { location, status: true }) + break + } + void Promise.all([ + result.location.mcp.refresh(event.location), + result.location.mcp.resource.refresh(event.location), + ]) + break + case "mcp.resources.changed": + if (bootstrapping) { + const location = event.location ?? defaultLocation() + const pending = pendingMcpRefresh.get(locationKey(location)) + pendingMcpRefresh.set(locationKey(location), { location, status: pending?.status ?? false }) + break + } + void result.location.mcp.resource.refresh(event.location) break } } @@ -938,9 +957,27 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.mcp }, async refresh(ref?: LocationRef) { - const result = await sdk.client.v2.mcp.list({ location: locationQuery(ref) }, { throwOnError: true }) - const key = locationKey(result.data.location) - setStore("location", key, { ...store.location[key], mcp: result.data.data }) + const result = await sdk.api["server.mcp"].list({ location: locationQuery(ref) }) + const key = locationKey(result.location) + setStore("location", key, { ...store.location[key], mcp: mutable(result.data) }) + }, + resource: { + catalog(location?: LocationRef) { + return store.location[locationKey(location ?? defaultLocation())]?.mcpResource + }, + async refresh(ref?: LocationRef) { + const result = await sdk.api["server.mcp"].resourceCatalog({ location: locationQuery(ref) }) + const key = locationKey(result.location) + setStore("location", key, { ...store.location[key], mcpResource: mutable(result.data) }) + }, + async read(input: { server: string; uri: string; location?: LocationRef }) { + const result = await sdk.api["server.mcp"].readResource({ + server: input.server, + uri: input.uri, + location: locationQuery(input.location), + }) + return result.data as ServerMcpReadResourceOutput["data"] + }, }, }, model: { @@ -1010,6 +1047,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ result.location.agent.refresh(), result.location.integration.refresh(), result.location.mcp.refresh(), + result.location.mcp.resource.refresh(), result.location.model.refresh(), result.location.provider.refresh(), result.location.reference.refresh(), @@ -1023,6 +1061,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) .finally(() => { bootstrapping = undefined + for (const pending of pendingMcpRefresh.values()) { + void Promise.all([ + ...(pending.status ? [result.location.mcp.refresh(pending.location)] : []), + result.location.mcp.resource.refresh(pending.location), + ]) + } + pendingMcpRefresh.clear() }) return bootstrapping } diff --git a/packages/tui/src/prompt/history.tsx b/packages/tui/src/prompt/history.tsx index fa69fedbb2..839373996f 100644 --- a/packages/tui/src/prompt/history.tsx +++ b/packages/tui/src/prompt/history.tsx @@ -2,6 +2,7 @@ import path from "path" import { onMount } from "solid-js" import { createStore, produce, unwrap } from "solid-js/store" import type { SessionPromptInput } from "@opencode-ai/client/promise" +import type { LocationRef } from "@opencode-ai/sdk/v2" import type { Types } from "effect" import { createSimpleContext } from "../context/helper" import { useTuiPaths } from "../context/runtime" @@ -16,7 +17,14 @@ export type PastedText = { } } -export type PromptInfo = Types.DeepMutable & { +type Prompt = Types.DeepMutable + +export type PromptFile = NonNullable[number] & { + mcp?: { server: string; uri: string; location: LocationRef } +} + +export type PromptInfo = Omit & { + files?: PromptFile[] pasted: PastedText[] mode?: "normal" | "shell" } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index b7b349a196..916cfd4cb8 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -108,6 +108,63 @@ test("refreshes resources into reactive getters", async () => { } }) +test("refreshes MCP resource catalogs after MCP events", async () => { + const events = createEventStream() + let resources = [{ server: "docs", name: "Readme", uri: "docs://readme" }] + let requests = 0 + const calls = createFetch((url) => { + if (url.pathname !== "/api/mcp/resource") return + requests++ + return json({ + location: { directory, project: { id: "proj_test", directory } }, + data: { resources, templates: [] }, + }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return {data.location.mcp.resource.catalog()?.resources[0]?.name ?? "missing"} + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => requests === 1) + expect(data.location.mcp.resource.catalog()?.resources[0]?.uri).toBe("docs://readme") + + resources = [{ server: "docs", name: "Guide", uri: "docs://guide" }] + emitEvent(events, { + id: "evt_mcp_resources", + created: 1, + type: "mcp.resources.changed", + data: { server: "docs" }, + }) + await wait(() => requests === 2 && data.location.mcp.resource.catalog()?.resources[0]?.name === "Guide") + + resources = [] + emitEvent(events, { + id: "evt_mcp_status", + created: 2, + type: "mcp.status.changed", + data: { server: "docs" }, + }) + await wait(() => requests === 3 && data.location.mcp.resource.catalog()?.resources.length === 0) + } finally { + app.renderer.destroy() + } +}) + test("restores running manual compaction before applying live deltas", async () => { const events = createEventStream() const calls = createFetch((url) => { diff --git a/packages/tui/test/cli/tui/mcp-resource.test.ts b/packages/tui/test/cli/tui/mcp-resource.test.ts new file mode 100644 index 0000000000..1757f0f2bf --- /dev/null +++ b/packages/tui/test/cli/tui/mcp-resource.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test" +import { materializeMcpResources } from "../../../src/component/prompt/mcp-resource" + +describe("MCP resource prompt attachments", () => { + test("materializes text and blob content while preserving one mention", async () => { + const calls: Array<{ server: string; uri: string; location: { directory: string } }> = [] + const files = await materializeMcpResources( + [ + { + uri: "docs://readme", + name: "Readme", + description: "Project docs", + mention: { start: 0, end: 7, text: "@Readme" }, + mcp: { server: "docs", uri: "docs://readme", location: { directory: "/tmp/project" } }, + }, + ], + async (input) => { + calls.push(input) + return { + server: input.server, + uri: input.uri, + contents: [ + { type: "text", uri: input.uri, text: "hello", mimeType: "text/plain" }, + { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" }, + ], + } + }, + ) + + expect(calls).toEqual([{ server: "docs", uri: "docs://readme", location: { directory: "/tmp/project" } }]) + expect(files).toEqual([ + { + uri: "data:text/plain;base64,aGVsbG8=", + name: "Readme", + description: "Project docs", + mention: { start: 0, end: 7, text: "@Readme" }, + }, + { + uri: "data:image/png;base64,aGVsbG8=", + name: "Readme-2", + description: "Project docs", + mention: undefined, + }, + ]) + }) + + test("fails when a resource is unavailable", async () => { + await expect( + materializeMcpResources( + [ + { + uri: "docs://missing", + mcp: { server: "docs", uri: "docs://missing", location: { directory: "/tmp/project" } }, + }, + ], + async () => null, + ), + ).rejects.toThrow("Unable to read MCP resource: docs:docs://missing") + }) +}) diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index 8c6e476db0..d515fec0ed 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -99,6 +99,11 @@ export function createFetch(override?: FetchHandler, events?: ReturnType