diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 2e68fdcd924..f5116f03702 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -231,6 +231,40 @@ export function Autocomplete(props: { }, ) + const mcpResources = createMemo(() => { + if (!store.visible || store.visible === "/") return [] + + const options: AutocompleteOption[] = [] + const width = props.anchor().width - 4 + + for (const res of Object.values(sync.data.mcp_resource)) { + options.push({ + display: Locale.truncateMiddle(`${res.name} (${res.uri})`, width), + description: res.description, + onSelect: () => { + insertPart(res.name, { + type: "file", + mime: res.mimeType ?? "text/plain", + filename: res.name, + url: res.uri, + source: { + type: "resource", + text: { + start: 0, + end: 0, + value: "", + }, + clientName: res.client, + uri: res.uri, + }, + }) + }, + }) + } + + return options + }) + const agents = createMemo(() => { const agents = sync.data.agent return agents @@ -416,7 +450,7 @@ export function Autocomplete(props: { const commandsValue = commands() const mixed: AutocompleteOption[] = ( - store.visible === "@" ? [...agentsValue, ...(filesValue || [])] : [...commandsValue] + store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue] ).filter((x) => x.disabled !== true) const currentFilter = filter() diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 893cc10ad9b..8daa70b7609 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -10,6 +10,7 @@ import type { PermissionRequest, LspStatus, McpStatus, + McpResource, FormatterStatus, SessionStatus, ProviderListResponse, @@ -62,6 +63,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ mcp: { [key: string]: McpStatus } + mcp_resource: { + [key: string]: McpResource + } formatter: FormatterStatus[] vcs: VcsInfo | undefined path: Path @@ -87,6 +91,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ part: {}, lsp: [], mcp: {}, + mcp_resource: {}, formatter: [], vcs: undefined, path: { state: "", config: "", worktree: "", directory: "" }, @@ -295,6 +300,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))), sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))), sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))), + sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))), sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))), sdk.client.session.status().then((x) => { setStore("session_status", reconcile(x.data!)) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 4aa981727ee..fb4a8d4cf81 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -28,6 +28,17 @@ export namespace MCP { const log = Log.create({ service: "mcp" }) const DEFAULT_TIMEOUT = 5000 + export const Resource = z + .object({ + name: z.string(), + uri: z.string(), + description: z.string().optional(), + mimeType: z.string().optional(), + client: z.string(), + }) + .meta({ ref: "McpResource" }) + export type Resource = z.infer + export const ToolsChanged = BusEvent.define( "mcp.tools.changed", z.object({ @@ -136,6 +147,7 @@ export namespace MCP { // Prompt cache types type PromptInfo = Awaited>["prompts"][number] + type ResourceInfo = Awaited>["resources"][number] type McpEntry = NonNullable[string] function isMcpConfigured(entry: McpEntry): entry is Config.Mcp { return typeof entry === "object" && entry !== null && "type" in entry @@ -213,6 +225,28 @@ export namespace MCP { return commands } + async function fetchResourcesForClient(clientName: string, client: Client) { + const resources = await client.listResources().catch((e) => { + log.error("failed to get prompts", { clientName, error: e.message }) + return undefined + }) + + if (!resources) { + return + } + + const commands: Record = {} + + for (const resource of resources.resources) { + const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_") + const sanitizedResourceName = resource.name.replace(/[^a-zA-Z0-9_-]/g, "_") + const key = sanitizedClientName + ":" + sanitizedResourceName + + commands[key] = { ...resource, client: clientName } + } + return commands + } + export async function add(name: string, mcp: Config.Mcp) { const s = await state() const result = await create(name, mcp) @@ -559,6 +593,27 @@ export namespace MCP { return prompts } + export async function resources() { + const s = await state() + const clientsSnapshot = await clients() + + const result = Object.fromEntries( + ( + await Promise.all( + Object.entries(clientsSnapshot).map(async ([clientName, client]) => { + if (s.status[clientName]?.status !== "connected") { + return [] + } + + return Object.entries((await fetchResourcesForClient(clientName, client)) ?? {}) + }), + ) + ).flat(), + ) + + return result + } + export async function getPrompt(clientName: string, name: string, args?: Record) { const clientsSnapshot = await clients() const client = clientsSnapshot[clientName] @@ -587,6 +642,33 @@ export namespace MCP { return result } + export async function readResource(clientName: string, resourceUri: string) { + const clientsSnapshot = await clients() + const client = clientsSnapshot[clientName] + + if (!client) { + log.warn("client not found for prompt", { + clientName: clientName, + }) + return undefined + } + + const result = await client + .readResource({ + uri: resourceUri, + }) + .catch((e) => { + log.error("failed to get prompt from MCP server", { + clientName: clientName, + resourceUri: resourceUri, + error: e.message, + }) + return undefined + }) + + return result + } + /** * Start OAuth authentication flow for an MCP server. * Returns the authorization URL that should be opened in a browser. diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 92bbecd1131..3789c3239c7 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -2337,6 +2337,27 @@ export namespace Server { return c.json(true) }, ) + .get( + "/experimental/resource", + describeRoute({ + summary: "Get MCP resources", + description: "Get all available MCP resources from connected servers. Optionally filter by name.", + operationId: "experimental.resource.list", + responses: { + 200: { + description: "MCP resources", + content: { + "application/json": { + schema: resolver(z.record(z.string(), MCP.Resource)), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await MCP.resources()) + }, + ) .get( "/lsp", describeRoute({ diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2f8b1720d3a..2dff17a5efa 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -117,7 +117,15 @@ export namespace MessageV2 { ref: "SymbolSource", }) - export const FilePartSource = z.discriminatedUnion("type", [FileSource, SymbolSource]).meta({ + export const ResourceSource = FilePartSourceBase.extend({ + type: z.literal("resource"), + clientName: z.string(), + uri: z.string(), + }).meta({ + ref: "ResourceSource", + }) + + export const FilePartSource = z.discriminatedUnion("type", [FileSource, SymbolSource, ResourceSource]).meta({ ref: "FilePartSource", }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fd03ef57e9b..fd7f8aa72a5 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -811,6 +811,78 @@ export namespace SessionPrompt { const parts = await Promise.all( input.parts.map(async (part): Promise => { if (part.type === "file") { + // before checking the protocol we check if this is an mcp resource because it needs special handling + if (part.source?.type === "resource") { + const { clientName, uri } = part.source + log.info("mcp resource", { clientName, uri, mime: part.mime }) + + const pieces: MessageV2.Part[] = [ + { + id: Identifier.ascending("part"), + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Reading MCP resource: ${part.filename} (${uri})`, + }, + ] + + try { + const resourceContent = await MCP.readResource(clientName, uri) + if (!resourceContent) { + throw new Error(`Resource not found: ${clientName}/${uri}`) + } + + // Handle different content types + const contents = Array.isArray(resourceContent.contents) + ? resourceContent.contents + : [resourceContent.contents] + + for (const content of contents) { + if ("text" in content && content.text) { + pieces.push({ + id: Identifier.ascending("part"), + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: content.text as string, + }) + } else if ("blob" in content && content.blob) { + // Handle binary content if needed + const mimeType = "mimeType" in content ? content.mimeType : part.mime + pieces.push({ + id: Identifier.ascending("part"), + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `[Binary content: ${mimeType}]`, + }) + } + } + + pieces.push({ + ...part, + id: part.id ?? Identifier.ascending("part"), + messageID: info.id, + sessionID: input.sessionID, + }) + } catch (error: unknown) { + log.error("failed to read MCP resource", { error, clientName, uri }) + const message = error instanceof Error ? error.message : String(error) + pieces.push({ + id: Identifier.ascending("part"), + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Failed to read MCP resource ${part.filename}: ${message}`, + }) + } + + return pieces + } const url = new URL(part.url) switch (url.protocol) { case "data:": diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 01de8c183eb..81d50b28edf 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -21,6 +21,7 @@ import type { EventTuiPromptAppend, EventTuiSessionSelect, EventTuiToastShow, + ExperimentalResourceListResponses, FileListResponses, FilePartInput, FileReadResponses, @@ -2431,6 +2432,31 @@ export class Mcp extends HeyApiClient { auth = new Auth({ client: this.client }) } +export class Resource extends HeyApiClient { + /** + * Get MCP resources + * + * Get all available MCP resources from connected servers. Optionally filter by name. + */ + public list( + parameters?: { + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) + return (options?.client ?? this.client).get({ + url: "/experimental/resource", + ...options, + ...params, + }) + } +} + +export class Experimental extends HeyApiClient { + resource = new Resource({ client: this.client }) +} + export class Lsp extends HeyApiClient { /** * Get LSP status @@ -2873,6 +2899,8 @@ export class OpencodeClient extends HeyApiClient { mcp = new Mcp({ client: this.client }) + experimental = new Experimental({ client: this.client }) + lsp = new Lsp({ client: this.client }) formatter = new Formatter({ client: this.client }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e42d2ed2257..5ca8fa8f6a2 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -252,7 +252,14 @@ export type SymbolSource = { kind: number } -export type FilePartSource = FileSource | SymbolSource +export type ResourceSource = { + text: FilePartSourceText + type: "resource" + clientName: string + uri: string +} + +export type FilePartSource = FileSource | SymbolSource | ResourceSource export type FilePart = { id: string @@ -1953,6 +1960,14 @@ export type McpStatus = | McpStatusNeedsAuth | McpStatusNeedsClientRegistration +export type McpResource = { + name: string + uri: string + description?: string + mimeType?: string + client: string +} + export type LspStatus = { id: string name: string @@ -4155,6 +4170,27 @@ export type McpDisconnectResponses = { export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses] +export type ExperimentalResourceListData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/experimental/resource" +} + +export type ExperimentalResourceListResponses = { + /** + * MCP resources + */ + 200: { + [key: string]: McpResource + } +} + +export type ExperimentalResourceListResponse = + ExperimentalResourceListResponses[keyof ExperimentalResourceListResponses] + export type LspStatusData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index bcfc98e1153..596c1bd2830 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -4541,6 +4541,44 @@ ] } }, + "/mcp/resources": { + "get": { + "operationId": "mcp.resources", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get MCP resources", + "description": "Get all available MCP resources from connected servers.", + "responses": { + "200": { + "description": "MCP resources", + "content": { + "application/json": { + "schema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.resources({\n ...\n})" + } + ] + } + }, "/lsp": { "get": { "operationId": "lsp.status", @@ -9842,7 +9880,7 @@ "maximum": 9007199254740991 } }, - "required": ["name", "mode", "permission", "options"] + "required": ["name", "mode", "builtIn", "permission", "options"] }, "MCPStatusConnected": { "type": "object",