Skip to content
Open
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
56 changes: 55 additions & 1 deletion packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { ErrorComponent } from "@tui/component/error-component"
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
import { ProjectProvider, useProject } from "@tui/context/project"
import { ProjectProvider } from "@tui/context/project"
import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading"
Expand Down Expand Up @@ -828,6 +828,60 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
return render({ params: route.data.data })
})

sdk.event.on("event", (evt) => {
if (evt.payload.type === "mcp.resource.updated") {
toast.show({
title: "Resource Updated",
message: `${evt.payload.properties.uri} (${evt.payload.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.payload.properties.server]
if (mcp && typeof mcp === "object" && "autoprompt" in mcp && mcp.autoprompt) {
const prompt = {
system: `An MCP resource has been updated. Resource URI: "${evt.payload.properties.uri}" from server "${evt.payload.properties.server}". Read the resource to review the latest content and take appropriate action.`,
parts: [
{
type: "text" as const,
text: `Resource updated: ${evt.payload.properties.uri} (${evt.payload.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))
}
return
}
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))
}
return
}

if (evt.payload.type === "mcp.resource.list.changed") {
toast.show({
title: "MCP Resources Changed",
message: `Server "${evt.payload.properties.server}" resource list updated`,
variant: "info",
duration: 3000,
})
}
})
return (
<box
width={dimensions().width}
Expand Down
16 changes: 16 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,14 @@ export namespace Config {
.positive()
.optional()
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
subscriptions: z
.array(z.string())
.optional()
.describe("Resource URIs to automatically subscribe to for update notifications"),
autoprompt: z
.boolean()
.optional()
.describe("Automatically prompt the AI when a subscribed resource is updated. Defaults to false."),
})
.strict()
.meta({
Expand Down Expand Up @@ -383,6 +391,14 @@ export namespace Config {
.positive()
.optional()
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
subscriptions: z
.array(z.string())
.optional()
.describe("Resource URIs to automatically subscribe to for update notifications"),
autoprompt: z
.boolean()
.optional()
.describe("Automatically prompt the AI when a subscribed resource is updated. Defaults to false."),
})
.strict()
.meta({
Expand Down
131 changes: 127 additions & 4 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import {
CallToolResultSchema,
ResourceListChangedNotificationSchema,
ResourceUpdatedNotificationSchema,
type Tool as MCPToolDef,
ToolListChangedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js"
Expand Down Expand Up @@ -60,6 +62,21 @@ export namespace MCP {
}),
)

export const ResourceUpdated = BusEvent.define(
"mcp.resource.updated",
z.object({
server: z.string(),
uri: z.string(),
}),
)

export const ResourceListChanged = BusEvent.define(
"mcp.resource.list.changed",
z.object({
server: z.string(),
}),
)

export const Failed = NamedError.create(
"MCPFailed",
z.object({
Expand Down Expand Up @@ -173,6 +190,10 @@ export namespace MCP {
)
}

function supportsSubscriptions(client: MCPClient) {
return client.getServerCapabilities()?.resources?.subscribe === true
}

function fetchFromClient<T extends { name: string }>(
clientName: string,
client: Client,
Expand Down Expand Up @@ -216,6 +237,7 @@ export namespace MCP {
status: Record<string, Status>
clients: Record<string, MCPClient>
defs: Record<string, MCPToolDef[]>
subscriptions: Map<string, Set<string>>
}

export interface Interface {
Expand All @@ -236,6 +258,9 @@ export namespace MCP {
clientName: string,
resourceUri: string,
) => Effect.Effect<Awaited<ReturnType<MCPClient["readResource"]>> | undefined>
readonly subscribe: (clientName: string, uri: string) => Effect.Effect<boolean>
readonly unsubscribe: (clientName: string, uri: string) => Effect.Effect<boolean>
readonly subscriptions: () => Effect.Effect<Record<string, string[]>>
readonly startAuth: (mcpName: string) => Effect.Effect<{ authorizationUrl: string; oauthState: string }>
readonly authenticate: (mcpName: string) => Effect.Effect<Status>
readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect<Status>
Expand Down Expand Up @@ -483,6 +508,19 @@ export namespace MCP {
s.defs[name] = listed
await bridge.promise(bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
})
client.setNotificationHandler(ResourceUpdatedNotificationSchema, async (msg) => {
log.info("resource updated notification received", {
server: name,
uri: msg.params.uri,
})
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
await bridge.promise(bus.publish(ResourceUpdated, { server: name, uri: msg.params.uri }).pipe(Effect.ignore))
})
client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => {
log.info("resource list changed notification received", { server: name })
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
await bridge.promise(bus.publish(ResourceListChanged, { server: name }).pipe(Effect.ignore))
})
}

const state = yield* InstanceState.make<State>(
Expand All @@ -494,6 +532,7 @@ export namespace MCP {
status: {},
clients: {},
defs: {},
subscriptions: new Map(),
}

yield* Effect.forEach(
Expand Down Expand Up @@ -644,7 +683,7 @@ export namespace MCP {
yield* Effect.forEach(
connectedClients,
([clientName, client]) =>
Effect.gen(function* () {
Effect.sync(() => {
const mcpConfig = config[clientName]
const entry = mcpConfig && isMcpConfigured(mcpConfig) ? mcpConfig : undefined

Expand Down Expand Up @@ -718,10 +757,91 @@ export namespace MCP {
})
})

const subscribe = Effect.fn("MCP.subscribe")(function* (clientName: string, uri: string) {
const s = yield* InstanceState.get(state)
const client = s.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
}

if (s.subscriptions.get(clientName)?.has(uri)) return true

return yield* Effect.tryPromise({
try: () => client.subscribeResource({ uri }),
catch: (e) => {
log.error("failed to subscribe to resource", {
clientName,
uri,
error: e instanceof Error ? e.message : String(e),
})
return e
},
}).pipe(
Effect.map(() => {
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
}),
Effect.orElseSucceed(() => false),
)
})

const unsubscribe = Effect.fn("MCP.unsubscribe")(function* (clientName: string, uri: string) {
const s = yield* InstanceState.get(state)
const client = s.clients[clientName]
s.subscriptions.get(clientName)?.delete(uri)

if (!client) {
log.warn("client not found for unsubscription", { clientName })
return false
}

if (!supportsSubscriptions(client)) return true

return yield* Effect.tryPromise({
try: () => client.unsubscribeResource({ uri }),
catch: (e) => {
log.error("failed to unsubscribe from resource", {
clientName,
uri,
error: e instanceof Error ? e.message : String(e),
})
return e
},
}).pipe(
Effect.map(() => {
log.info("unsubscribed from resource", { clientName, uri })
return true
}),
Effect.orElseSucceed(() => false),
)
})

const subscriptions = Effect.fn("MCP.subscriptions")(function* () {
const s = yield* InstanceState.get(state)
return Object.fromEntries(
[...s.subscriptions].filter(([, uris]) => uris.size > 0).map(([server, uris]) => [server, [...uris]]),
)
})

const readResource = Effect.fn("MCP.readResource")(function* (clientName: string, resourceUri: string) {
return yield* withClient(clientName, (client) => client.readResource({ uri: resourceUri }), "readResource", {
resourceUri,
})
const result = yield* withClient(
clientName,
(client) => client.readResource({ uri: resourceUri }),
"readResource",
{
resourceUri,
},
)
if (result) yield* subscribe(clientName, resourceUri)
return result
})

const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
Expand Down Expand Up @@ -905,6 +1025,9 @@ export namespace MCP {
disconnect,
getPrompt,
readResource,
subscribe,
unsubscribe,
subscriptions,
startAuth,
authenticate,
finishAuth,
Expand Down
Loading
Loading