diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 2058f1c527..4bc73f231b 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -664,6 +664,20 @@ export const PromptInput: Component = (props) => { .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), ) + const mcpResourceList = createMemo(() => + Object.values(sync().data.mcp_resource).map( + (resource): AtOption => ({ + type: "resource", + name: resource.name, + uri: resource.uri, + client: resource.client, + display: resource.name, + description: resource.description, + mime: resource.mimeType, + }), + ), + ) + const handleAtSelect = (option: AtOption | undefined) => { if (!option) return if (option.type === "agent") { @@ -682,6 +696,25 @@ export const PromptInput: Component = (props) => { }) return } + if (option.type === "resource") { + addPart({ + type: "file", + path: option.uri, + content: "@" + option.name, + start: 0, + end: 0, + mime: option.mime ?? "text/plain", + filename: option.name, + url: option.uri, + source: { + type: "resource", + text: { value: "@" + option.name, start: 0, end: 0 }, + clientName: option.client, + uri: option.uri, + }, + }) + return + } addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) } @@ -689,6 +722,7 @@ export const PromptInput: Component = (props) => { if (!x) return "" if (x.type === "agent") return `agent:${x.name}` if (x.type === "reference") return `reference:${x.name}` + if (x.type === "resource") return `resource:${x.client}:${x.uri}` return `file:${x.path}` } @@ -702,15 +736,16 @@ export const PromptInput: Component = (props) => { items: async (query) => { const references = referenceList() const agents = agentList() + const mcpResources = mcpResourceList() const open = recent() const seen = new Set(open) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) - if (!query.trim()) return [...references, ...agents, ...pinned] + if (!query.trim()) return [...references, ...agents, ...mcpResources, ...pinned] const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) .map((path) => ({ type: "file", path, display: path })) - return [...references, ...agents, ...pinned, ...fileOptions] + return [...references, ...agents, ...mcpResources, ...pinned, ...fileOptions] }, key: atKey, filterKeys: ["display"], @@ -718,6 +753,7 @@ export const PromptInput: Component = (props) => { groupBy: (item) => { if (item.type === "reference") return "reference" if (item.type === "agent") return "agent" + if (item.type === "resource") return "resource" if (item.recent) return "recent" return "file" }, @@ -725,8 +761,9 @@ export const PromptInput: Component = (props) => { const rank = (category: string) => { if (category === "reference") return 0 if (category === "agent") return 1 - if (category === "recent") return 2 - return 3 + if (category === "resource") return 2 + if (category === "recent") return 3 + return 4 } return rank(a.category) - rank(b.category) }, @@ -796,6 +833,12 @@ export const PromptInput: Component = (props) => { pill.setAttribute("data-path", part.path) if (part.mime) pill.setAttribute("data-mime", part.mime) if (part.filename) pill.setAttribute("data-filename", part.filename) + if (part.url) pill.setAttribute("data-url", part.url) + if (part.source?.type === "resource") { + pill.setAttribute("data-source-type", part.source.type) + pill.setAttribute("data-source-client-name", part.source.clientName) + pill.setAttribute("data-source-uri", part.source.uri) + } } if (part.type === "agent") pill.setAttribute("data-name", part.name) pill.setAttribute("contenteditable", "false") @@ -911,6 +954,19 @@ export const PromptInput: Component = (props) => { const pushFile = (file: HTMLElement) => { const content = file.textContent ?? "" + const source = + file.dataset.sourceType === "resource" && file.dataset.sourceClientName && file.dataset.sourceUri + ? { + type: "resource" as const, + text: { + value: content, + start: position, + end: position + content.length, + }, + clientName: file.dataset.sourceClientName, + uri: file.dataset.sourceUri, + } + : undefined parts.push({ type: "file", path: file.dataset.path!, @@ -919,6 +975,8 @@ export const PromptInput: Component = (props) => { end: position + content.length, ...(file.dataset.mime ? { mime: file.dataset.mime } : {}), ...(file.dataset.filename ? { filename: file.dataset.filename } : {}), + ...(file.dataset.url ? { url: file.dataset.url } : {}), + ...(source ? { source } : {}), }) position += content.length } diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts index 2654d1442f..c5bdd730c4 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -99,21 +99,31 @@ export function buildRequestParts(input: BuildRequestPartsInput) { const files = input.prompt.filter(isFileAttachment).map((attachment) => { const path = absolute(input.sessionDirectory, attachment.path) + const source = attachment.source + ? { + ...attachment.source, + text: { + value: attachment.content, + start: attachment.start, + end: attachment.end, + }, + } + : { + type: "file" as const, + text: { + value: attachment.content, + start: attachment.start, + end: attachment.end, + }, + path, + } return { id: Identifier.ascending("part"), type: "file", mime: attachment.mime ?? "text/plain", - url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`, + url: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`, filename: attachment.filename ?? getFilename(attachment.path), - source: { - type: "file", - text: { - value: attachment.content, - start: attachment.start, - end: attachment.end, - }, - path, - }, + source, } satisfies PromptRequestPart }) diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index e179dc63ff..218a4fae78 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -7,6 +7,7 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path" export type AtOption = | { type: "agent"; name: string; display: string } + | { type: "resource"; name: string; uri: string; client: string; display: string; description?: string; mime?: string } | { type: "reference"; name: string; path: string; display: string; description: string } | { type: "file"; path: string; display: string; recent?: boolean } @@ -104,18 +105,89 @@ export const PromptPopover: Component = (props) => { ) } + if (item.type === "resource") { + return ( + + ) + } + if (item.type === "reference") { return ( ) diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index 137649c024..73de5b9ce6 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -36,6 +36,7 @@ describe("bootstrapDirectory", () => { question: {}, mcp_ready: true, mcp: {}, + mcp_resource: {}, lsp_ready: true, lsp: [], vcs: undefined, diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index b162034830..8f253a975a 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -19,7 +19,7 @@ import type { ServerSession } from "../server-session" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { formatServerError } from "@/utils/server-errors" import { QueryClient, queryOptions } from "@tanstack/solid-query" -import { loadMcpQuery } from "../server-sync" +import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { ScopedKey, type ServerScope } from "@/utils/server-scope" @@ -348,6 +348,7 @@ export async function bootstrapDirectory(input: { ), () => Promise.resolve(input.loadSessions(input.directory)), input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))), + input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))), () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => { const project = getFilename(input.directory) diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index 31c7519d84..ddbfffe1ad 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -34,7 +34,9 @@ const queryOptionsApi = { }), agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }), mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }), + mcpResources: (directory: string) => ({ queryKey: [directory, "mcpResources"], queryFn: async () => ({}) }), lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }), + references: (directory: string) => ({ queryKey: [directory, "references"], queryFn: async () => [] }), sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }), } as unknown as QueryOptionsApi @@ -197,14 +199,18 @@ describe("createChildStoreManager", () => { try { if (!manager) throw new Error("manager required") const [store, setStore] = manager.child("/project", { bootstrap: false }) - expect(querySingles.length - offset).toBe(4) + expect(querySingles.length - offset).toBe(6) const query = querySingles[offset + 1] + const resourceQuery = querySingles[offset + 2] if (!query) throw new Error("query required") + if (!resourceQuery) throw new Error("resource query required") expect(query().enabled).toBe(false) + expect(resourceQuery().enabled).toBe(false) setStore("status", "complete") manager.child("/project", { bootstrap: false, mcp: true }) expect(query().enabled).toBe(true) + expect(resourceQuery().enabled).toBe(true) expect(store.mcp).toEqual({ demo: { status: "disabled" } }) expect(mcpLoads).toEqual(["/project"]) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 3e7af23309..53b9536182 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -185,6 +185,7 @@ export function createChildStoreManager(input: { const pathQuery = useQuery(() => input.queryOptions.path(key)) const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() })) + const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() })) const lspQuery = useQuery(() => input.queryOptions.lsp(key)) const providerQuery = useQuery(() => input.queryOptions.providers(key)) const referenceQuery = useQuery(() => input.queryOptions.references(key)) @@ -231,6 +232,9 @@ export function createChildStoreManager(input: { get mcp() { return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {}) }, + get mcp_resource() { + return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {}) + }, get lsp_ready() { return !lspQuery.isLoading }, diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index c3b38cd315..86b489cd09 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -3,6 +3,7 @@ import type { Command, Config, LspStatus, + McpResource, McpStatus, Message, Part, @@ -65,6 +66,9 @@ export type State = { mcp: { [name: string]: McpStatus } + mcp_resource: { + [key: string]: McpResource + } lsp_ready: boolean lsp: LspStatus[] vcs: VcsInfo | undefined diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 5c00d02a98..c361493516 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -12,6 +12,7 @@ import { useTabs, type Tab } from "./tabs" import { ServerConnection } from "./server" import { requireServerKey } from "@/utils/session-route" import { useSettings } from "./settings" +import type { FilePartSource } from "@opencode-ai/sdk/v2/client" interface PartBase { content: string @@ -29,6 +30,8 @@ export interface FileAttachmentPart extends PartBase { selection?: FileSelection mime?: string filename?: string + url?: string + source?: FilePartSource } export interface AgentPart extends PartBase { diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index b01a704ba7..9ff032a09d 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -1,4 +1,4 @@ -import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse } from "@opencode-ai/sdk/v2/client" +import type { Config, McpResource, OpencodeClient, Path, Project, ProviderAuthResponse } from "@opencode-ai/sdk/v2/client" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js" @@ -57,6 +57,13 @@ export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: Opencod queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}), }) +export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => + queryOptions>({ + queryKey: [scope, directory, "mcpResources"] as const, + queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}), + placeholderData: {}, + }) + export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => queryOptions({ queryKey: [scope, directory, "lsp"] as const, @@ -78,6 +85,7 @@ function makeQueryOptionsApi( agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)), references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)), mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)), + mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)), lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)), sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }), } @@ -489,6 +497,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { }, refresh: async () => { await queryClient.refetchQueries(queryOptionsApi.mcp(key)) + await queryClient.refetchQueries(queryOptionsApi.mcpResources(key)) }, }) },