summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorPaolo Ricciuti <[email protected]>2026-01-04 16:12:54 +0100
committerGitHub <[email protected]>2026-01-04 09:12:54 -0600
commit21dc3c24d9243d81fdc1c3b23aa1f160df213a50 (patch)
tree23a8f343c93a1a4d2fa418c8329433ecd7686708
parente00621cb171b21b2d1d7fd8c55b04601a469a214 (diff)
downloadopencode-21dc3c24d9243d81fdc1c3b23aa1f160df213a50.tar.gz
opencode-21dc3c24d9243d81fdc1c3b23aa1f160df213a50.zip
feat: mcp resources (#6542)
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx36
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/sync.tsx6
-rw-r--r--packages/opencode/src/mcp/index.ts82
-rw-r--r--packages/opencode/src/server/server.ts21
-rw-r--r--packages/opencode/src/session/message-v2.ts10
-rw-r--r--packages/opencode/src/session/prompt.ts72
-rw-r--r--packages/sdk/js/src/v2/gen/sdk.gen.ts28
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts38
-rw-r--r--packages/sdk/openapi.json40
9 files changed, 329 insertions, 4 deletions
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 2e68fdcd9..f5116f037 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 893cc10ad..8daa70b76 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 4aa981727..fb4a8d4cf 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<typeof Resource>
+
export const ToolsChanged = BusEvent.define(
"mcp.tools.changed",
z.object({
@@ -136,6 +147,7 @@ export namespace MCP {
// Prompt cache types
type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
+ type ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"][number]
type McpEntry = NonNullable<Config.Info["mcp"]>[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<string, ResourceInfo & { client: string }> = {}
+
+ 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<ResourceInfo & { client: string }>(
+ (
+ 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<string, string>) {
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 92bbecd11..3789c3239 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -2338,6 +2338,27 @@ export namespace Server {
},
)
.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({
summary: "Get LSP status",
diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts
index 2f8b1720d..2dff17a5e 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 fd03ef57e..fd7f8aa72 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<MessageV2.Part[]> => {
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 01de8c183..81d50b28e 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<ThrowOnError extends boolean = false>(
+ parameters?: {
+ directory?: string
+ },
+ options?: Options<never, ThrowOnError>,
+ ) {
+ const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+ return (options?.client ?? this.client).get<ExperimentalResourceListResponses, unknown, ThrowOnError>({
+ 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 e42d2ed22..5ca8fa8f6 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 bcfc98e11..596c1bd28 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",