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
54 changes: 54 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,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
12 changes: 12 additions & 0 deletions packages/opencode/src/config/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ export const Local = Schema.Struct({
timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
subscriptions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Resource URIs to automatically subscribe to for update notifications",
}),
autoprompt: Schema.optional(Schema.Boolean).annotate({
description: "Automatically prompt the AI when a subscribed resource is updated. Defaults to false.",
}),
}).annotate({ identifier: "McpLocalConfig" })
export type Local = Schema.Schema.Type<typeof Local>

Expand Down Expand Up @@ -51,6 +57,12 @@ export const Remote = Schema.Struct({
timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
subscriptions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Resource URIs to automatically subscribe to for update notifications",
}),
autoprompt: Schema.optional(Schema.Boolean).annotate({
description: "Automatically prompt the AI when a subscribed resource is updated. Defaults to false.",
}),
}).annotate({ identifier: "McpRemoteConfig" })
export type Remote = Schema.Schema.Type<typeof Remote>

Expand Down
129 changes: 126 additions & 3 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
CallToolResultSchema,
ListToolsResultSchema,
ToolSchema,
ResourceListChangedNotificationSchema,
ResourceUpdatedNotificationSchema,
type Tool as MCPToolDef,
ToolListChangedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js"
Expand Down Expand Up @@ -63,6 +65,21 @@ export const BrowserOpenFailed = BusEvent.define(
}),
)

export const ResourceUpdated = BusEvent.define(
"mcp.resource.updated",
Schema.Struct({
server: Schema.String,
uri: Schema.String,
}),
)

export const ResourceListChanged = BusEvent.define(
"mcp.resource.list.changed",
Schema.Struct({
server: Schema.String,
}),
)

export const Failed = NamedError.create("MCPFailed", {
name: Schema.String,
})
Expand Down Expand Up @@ -219,6 +236,10 @@ function fetchFromClient<T extends { name: string }>(
)
}

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

interface CreateResult {
mcpClient?: MCPClient
status: Status
Expand All @@ -237,6 +258,7 @@ interface State {
status: Record<string, Status>
clients: Record<string, MCPClient>
defs: Record<string, MCPToolDef[]>
subscriptions: Map<string, Set<string>>
}

export interface Interface {
Expand All @@ -257,6 +279,9 @@ export interface Interface {
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 }, NotFoundError>
Expand Down Expand Up @@ -517,6 +542,19 @@ export const layer = Layer.effect(
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 @@ -528,6 +566,7 @@ export const layer = Layer.effect(
status: {},
clients: {},
defs: {},
subscriptions: new Map(),
}

yield* Effect.forEach(
Expand Down Expand Up @@ -749,10 +788,91 @@ export const layer = Layer.effect(
})
})

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 subscriptionsList = 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 @@ -945,6 +1065,9 @@ export const layer = Layer.effect(
disconnect,
getPrompt,
readResource,
subscribe,
unsubscribe,
subscriptions: subscriptionsList,
startAuth,
authenticate,
finishAuth,
Expand Down
19 changes: 13 additions & 6 deletions packages/opencode/test/mcp/headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ beforeEach(() => {
const { MCP } = await import("../../src/mcp/index")
const it = testEffect(MCP.defaultLayer)

function targetCalls() {
return transportCalls.filter((call) => call.url === "https://example.com/mcp")
}

describe("mcp.headers", () => {
it.instance("headers are passed to transports when oauth is enabled (default)", () =>
Effect.gen(function* () {
Expand All @@ -64,9 +68,10 @@ describe("mcp.headers", () => {
.pipe(Effect.catch(() => Effect.void))

// Both transports should have been created with headers
expect(transportCalls.length).toBeGreaterThanOrEqual(1)
const calls = targetCalls()
expect(calls.length).toBeGreaterThanOrEqual(1)

for (const call of transportCalls) {
for (const call of calls) {
expect(call.options.requestInit).toBeDefined()
expect(call.options.requestInit?.headers).toEqual({
Authorization: "Bearer test-token",
Expand All @@ -92,9 +97,10 @@ describe("mcp.headers", () => {
})
.pipe(Effect.catch(() => Effect.void))

expect(transportCalls.length).toBeGreaterThanOrEqual(1)
const calls = targetCalls()
expect(calls.length).toBeGreaterThanOrEqual(1)

for (const call of transportCalls) {
for (const call of calls) {
expect(call.options.requestInit).toBeDefined()
expect(call.options.requestInit?.headers).toEqual({
Authorization: "Bearer test-token",
Expand All @@ -115,9 +121,10 @@ describe("mcp.headers", () => {
})
.pipe(Effect.catch(() => Effect.void))

expect(transportCalls.length).toBeGreaterThanOrEqual(1)
const calls = targetCalls()
expect(calls.length).toBeGreaterThanOrEqual(1)

for (const call of transportCalls) {
for (const call of calls) {
// No headers means requestInit should be undefined
expect(call.options.requestInit).toBeUndefined()
}
Expand Down
Loading
Loading