diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index ab3d09689252..42aef1908249 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -6,6 +6,7 @@ import { RouteProvider, useRoute } from "@tui/context/route" import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32" import { Installation } from "@/installation" +import { MCP } from "@/mcp" import { Flag } from "@/flag/flag" import { DialogProvider, useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" @@ -729,6 +730,58 @@ function App() { }) }) + sdk.event.on(MCP.ResourceUpdated.type, (evt) => { + toast.show({ + title: "Resource Updated", + message: `${evt.properties.uri} (${evt.properties.server})`, + variant: "info", + duration: 5000, + }) + + // If autoprompt is enabled for this server, trigger AI with updated resource info + const mcp = sync.data.config.mcp?.[evt.properties.server] + if (mcp && typeof mcp === "object" && "autoprompt" in mcp && mcp.autoprompt) { + const prompt = { + system: `An MCP resource has been updated. Resource URI: "${evt.properties.uri}" from server "${evt.properties.server}". Read the resource to review the latest content and take appropriate action.`, + parts: [ + { + type: "text" as const, + text: `Resource updated: ${evt.properties.uri} (${evt.properties.server})`, + }, + ], + } + if (route.data.type === "session") { + const status = sync.data.session_status?.[route.data.sessionID] + if (!status || status.type === "idle") { + sdk.client.session + .promptAsync({ sessionID: route.data.sessionID, ...prompt }) + .catch((e) => console.error("failed to trigger AI for resource update", e)) + } + } else { + sdk.client.session + .create({}) + .then((res) => { + const id = res.data?.id + if (!id) return + route.navigate({ type: "session", sessionID: id }) + sdk.client.session + .promptAsync({ sessionID: id, ...prompt }) + .catch((e) => console.error("failed to trigger AI for resource update", e)) + }) + .catch((e) => console.error("failed to create session for resource update", e)) + } + } + }) + + sdk.event.on(MCP.ResourceListChanged.type, (evt) => { + toast.show({ + title: "MCP Resources Changed", + message: `Server "${evt.properties.server}" resource list updated`, + variant: "info", + duration: 3000, + }) + }) + return ( { + const uri = notification.params.uri + log.info("resource updated notification received", { server: serverName, uri }) + Bus.publish(ResourceUpdated, { server: serverName, uri }) + }) + + client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => { + log.info("resource list changed notification received", { server: serverName }) + Bus.publish(ResourceListChanged, { server: serverName }) + // Subscribe to any newly listed resources + if (supportsSubscriptions(client)) { + const s = await state() + const existing = s.subscriptions.get(serverName) ?? new Set() + const listed = await client.listResources().catch((e) => { + log.warn("failed to list resources after list changed", { server: serverName, error: e instanceof Error ? e.message : String(e) }) + return undefined + }) + listed?.resources + .filter((r) => !existing.has(r.uri)) + .forEach((r) => { + existing.add(r.uri) + client + .subscribeResource({ uri: r.uri }) + .then(() => { + log.info("subscribed to new resource", { server: serverName, uri: r.uri }) + }) + .catch((e) => { + existing.delete(r.uri) + log.error("failed to subscribe to new resource", { server: serverName, uri: r.uri, error: e instanceof Error ? e.message : String(e) }) + }) + }) + if (existing.size > 0) s.subscriptions.set(serverName, existing) + } + }) + } + + function supportsSubscriptions(client: MCPClient): boolean { + return client.getServerCapabilities()?.resources?.subscribe === true } // Convert MCP tool definition to AI SDK Tool type @@ -190,9 +246,44 @@ export namespace MCP { } }), ) + const subscriptionMap = new Map>() + + // Auto-subscribe to all resources for servers that support subscriptions + await Promise.all( + Object.entries(clients) + .filter(([key]) => status[key]?.status === "connected" && supportsSubscriptions(clients[key])) + .map(async ([key, client]) => { + const mcp = config[key] + const cfg = isMcpConfigured(mcp) && mcp.subscriptions ? mcp.subscriptions : [] + const listed = await client.listResources().catch((e) => { + log.warn("failed to list resources for auto-subscribe", { key, error: e instanceof Error ? e.message : String(e) }) + return undefined + }) + const uris = new Set([...cfg, ...(listed?.resources.map((r) => r.uri) ?? [])]) + if (uris.size === 0) return + uris.forEach((uri) => { + client + .subscribeResource({ uri }) + .then(() => { + log.info("auto-subscribed to resource", { key, uri }) + }) + .catch((e) => { + uris.delete(uri) + log.error("failed to auto-subscribe to resource", { + key, + uri, + error: e instanceof Error ? e.message : String(e), + }) + }) + }) + subscriptionMap.set(key, uris) + }), + ) + return { status, clients, + subscriptions: subscriptionMap, } }, async (state) => { @@ -548,6 +639,38 @@ export namespace MCP { }) } s.clients[name] = result.mcpClient + + // Re-subscribe: merge previous subscriptions, config URIs, and freshly listed resources + if (supportsSubscriptions(result.mcpClient)) { + const cfg = isMcpConfigured(mcp) && mcp.subscriptions ? mcp.subscriptions : [] + const listed = await result.mcpClient.listResources().catch((e) => { + log.warn("failed to list resources on reconnect", { name, error: e instanceof Error ? e.message : String(e) }) + return undefined + }) + const tracked = new Set([ + ...(s.subscriptions.get(name) ?? []), + ...cfg, + ...(listed?.resources.map((r) => r.uri) ?? []), + ]) + if (tracked.size > 0) { + tracked.forEach((uri) => { + result.mcpClient! + .subscribeResource({ uri }) + .then(() => { + log.info("re-subscribed to resource", { name, uri }) + }) + .catch((e) => { + tracked.delete(uri) + log.error("failed to re-subscribe to resource", { + name, + uri, + error: e instanceof Error ? e.message : String(e), + }) + }) + }) + s.subscriptions.set(name, tracked) + } + } } } @@ -560,6 +683,7 @@ export namespace MCP { }) delete s.clients[name] } + s.subscriptions.delete(name) s.status[name] = { status: "disabled" } } @@ -680,7 +804,7 @@ export namespace MCP { const client = clientsSnapshot[clientName] if (!client) { - log.warn("client not found for prompt", { + log.warn("client not found for resource", { clientName: clientName, }) return undefined @@ -691,7 +815,7 @@ export namespace MCP { uri: resourceUri, }) .catch((e) => { - log.error("failed to get prompt from MCP server", { + log.error("failed to read resource from MCP server", { clientName: clientName, resourceUri: resourceUri, error: e.message, @@ -699,9 +823,93 @@ export namespace MCP { return undefined }) + // Auto-subscribe to the resource for update notifications (fire-and-forget) + if (result) { + subscribe(clientName, resourceUri).catch(() => {}) + } + return result } + export async function subscribe(clientName: string, uri: string): Promise { + const client = (await clients())[clientName] + + if (!client) { + log.warn("client not found for subscription", { clientName }) + return false + } + + if (!supportsSubscriptions(client)) { + log.debug("server does not support resource subscriptions", { clientName }) + return false + } + + const s = await state() + if (s.subscriptions.get(clientName)?.has(uri)) { + return true // already subscribed + } + + return client + .subscribeResource({ uri }) + .then(() => { + if (!s.subscriptions.has(clientName)) { + s.subscriptions.set(clientName, new Set()) + } + s.subscriptions.get(clientName)!.add(uri) + log.info("subscribed to resource", { clientName, uri }) + return true + }) + .catch((e) => { + log.error("failed to subscribe to resource", { + clientName, + uri, + error: e instanceof Error ? e.message : String(e), + }) + return false + }) + } + + export async function unsubscribe(clientName: string, uri: string): Promise { + const client = (await clients())[clientName] + const s = await state() + + // Remove from tracking regardless of whether the server call succeeds + s.subscriptions.get(clientName)?.delete(uri) + + if (!client) { + log.warn("client not found for unsubscription", { clientName }) + return false + } + + if (!supportsSubscriptions(client)) { + return true // already removed from tracking above + } + + return client + .unsubscribeResource({ uri }) + .then(() => { + log.info("unsubscribed from resource", { clientName, uri }) + return true + }) + .catch((e) => { + log.error("failed to unsubscribe from resource", { + clientName, + uri, + error: e instanceof Error ? e.message : String(e), + }) + return false + }) + } + + export async function subscriptions(): Promise> { + const s = await state() + return Object.fromEntries( + [...s.subscriptions] + .filter(([, uris]) => uris.size > 0) + .map(([server, uris]) => [server, [...uris]]), + ) + } + /** * Start OAuth authentication flow for an MCP server. * Returns the authorization URL that should be opened in a browser. diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index efb7e202e120..416aaedc71e0 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -784,6 +784,21 @@ export type EventMcpBrowserOpenFailed = { } } +export type EventMcpResourceUpdated = { + type: "mcp.resource.updated" + properties: { + server: string + uri: string + } +} + +export type EventMcpResourceListChanged = { + type: "mcp.resource.list.changed" + properties: { + server: string + } +} + export type EventCommandExecuted = { type: "command.executed" properties: { @@ -972,6 +987,8 @@ export type Event = | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed + | EventMcpResourceUpdated + | EventMcpResourceListChanged | EventCommandExecuted | EventSessionCreated | EventSessionUpdated @@ -1616,6 +1633,14 @@ export type McpLocalConfig = { * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. */ timeout?: number + /** + * Resource URIs to automatically subscribe to for update notifications + */ + subscriptions?: Array + /** + * Automatically prompt the AI when a subscribed resource is updated. Defaults to false. + */ + autoprompt?: boolean } export type McpOAuthConfig = { @@ -1660,6 +1685,14 @@ export type McpRemoteConfig = { * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. */ timeout?: number + /** + * Resource URIs to automatically subscribe to for update notifications + */ + subscriptions?: Array + /** + * Automatically prompt the AI when a subscribed resource is updated. Defaults to false. + */ + autoprompt?: boolean } /**