Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
PermissionRequest,
LspStatus,
McpStatus,
McpResource,
FormatterStatus,
SessionStatus,
ProviderListResponse,
Expand Down Expand Up @@ -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
Expand All @@ -87,6 +91,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
part: {},
lsp: [],
mcp: {},
mcp_resource: {},
formatter: [],
vcs: undefined,
path: { state: "", config: "", worktree: "", directory: "" },
Expand Down Expand Up @@ -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!))
Expand Down
82 changes: 82 additions & 0 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
10 changes: 9 additions & 1 deletion packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
})

Expand Down
72 changes: 72 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:":
Expand Down
28 changes: 28 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
EventTuiPromptAppend,
EventTuiSessionSelect,
EventTuiToastShow,
ExperimentalResourceListResponses,
FileListResponses,
FilePartInput,
FileReadResponses,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 })
Expand Down
Loading