Compare commits
1 commit
dev
...
fix/mcp-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b880d7d5a1 |
3 changed files with 107 additions and 14 deletions
|
|
@ -7,6 +7,7 @@ import { Config } from "@/config/config"
|
||||||
import { MCP } from "../mcp"
|
import { MCP } from "../mcp"
|
||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||||
import PROMPT_INITIALIZE from "./template/initialize.txt"
|
import PROMPT_INITIALIZE from "./template/initialize.txt"
|
||||||
import PROMPT_REVIEW from "./template/review.txt"
|
import PROMPT_REVIEW from "./template/review.txt"
|
||||||
|
|
||||||
|
|
@ -68,6 +69,7 @@ export const layer = Layer.effect(
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const mcp = yield* MCP.Service
|
const mcp = yield* MCP.Service
|
||||||
const skill = yield* Skill.Service
|
const skill = yield* Skill.Service
|
||||||
|
const events = yield* EventV2Bridge.Service
|
||||||
|
|
||||||
const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) {
|
const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) {
|
||||||
const cfg = yield* config.get()
|
const cfg = yield* config.get()
|
||||||
|
|
@ -157,6 +159,11 @@ export const layer = Layer.effect(
|
||||||
})
|
})
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>((ctx) => init(ctx))
|
const state = yield* InstanceState.make<State>((ctx) => init(ctx))
|
||||||
|
const unsubscribe = yield* events.listen((event) => {
|
||||||
|
if (event.type !== MCP.PromptsChanged.type) return Effect.void
|
||||||
|
return InstanceState.invalidate(state)
|
||||||
|
})
|
||||||
|
yield* Effect.addFinalizer(() => unsubscribe)
|
||||||
|
|
||||||
const get = Effect.fn("Command.get")(function* (name: string) {
|
const get = Effect.fn("Command.get")(function* (name: string) {
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(state)
|
||||||
|
|
@ -176,6 +183,7 @@ export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(MCP.defaultLayer),
|
Layer.provide(MCP.defaultLayer),
|
||||||
Layer.provide(Skill.defaultLayer),
|
Layer.provide(Skill.defaultLayer),
|
||||||
|
Layer.provide(EventV2Bridge.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
export * as Command from "."
|
export * as Command from "."
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||||
import {
|
import {
|
||||||
CallToolResultSchema,
|
CallToolResultSchema,
|
||||||
ListToolsResultSchema,
|
ListToolsResultSchema,
|
||||||
|
PromptListChangedNotificationSchema,
|
||||||
ToolSchema,
|
ToolSchema,
|
||||||
type Tool as MCPToolDef,
|
type Tool as MCPToolDef,
|
||||||
ToolListChangedNotificationSchema,
|
ToolListChangedNotificationSchema,
|
||||||
|
|
@ -56,6 +57,13 @@ export const ToolsChanged = EventV2.define({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const PromptsChanged = EventV2.define({
|
||||||
|
type: "mcp.prompts.changed",
|
||||||
|
schema: {
|
||||||
|
server: Schema.String,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const BrowserOpenFailed = EventV2.define({
|
export const BrowserOpenFailed = EventV2.define({
|
||||||
type: "mcp.browser.open.failed",
|
type: "mcp.browser.open.failed",
|
||||||
schema: {
|
schema: {
|
||||||
|
|
@ -508,18 +516,27 @@ export const layer = Layer.effect(
|
||||||
)
|
)
|
||||||
|
|
||||||
function watch(s: State, name: string, client: MCPClient, bridge: EffectBridge.Shape, timeout?: number) {
|
function watch(s: State, name: string, client: MCPClient, bridge: EffectBridge.Shape, timeout?: number) {
|
||||||
if (!client.getServerCapabilities()?.tools) return
|
const capabilities = client.getServerCapabilities()
|
||||||
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
if (capabilities?.tools) {
|
||||||
log.info("tools list changed notification received", { server: name })
|
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
||||||
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
|
log.info("tools list changed notification received", { server: name })
|
||||||
|
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
|
||||||
|
|
||||||
const listed = await bridge.promise(defs(name, client, timeout))
|
const listed = await bridge.promise(defs(name, client, timeout))
|
||||||
if (!listed) return
|
if (!listed) return
|
||||||
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
|
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
|
||||||
|
|
||||||
s.defs[name] = listed
|
s.defs[name] = listed
|
||||||
await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
|
await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
if (capabilities?.prompts?.listChanged) {
|
||||||
|
client.setNotificationHandler(PromptListChangedNotificationSchema, async () => {
|
||||||
|
log.info("prompts list changed notification received", { server: name })
|
||||||
|
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
|
||||||
|
await bridge.promise(events.publish(PromptsChanged, { server: name }).pipe(Effect.ignore))
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
const state = yield* InstanceState.make<State>(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { expect, mock, beforeEach } from "bun:test"
|
import { expect, mock, beforeEach } from "bun:test"
|
||||||
import { Cause, Effect, Exit } from "effect"
|
import { Cause, Effect, Exit, Layer } from "effect"
|
||||||
import type { MCP as MCPNS } from "../../src/mcp/index"
|
import type { MCP as MCPNS } from "../../src/mcp/index"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
|
|
@ -191,9 +191,10 @@ beforeEach(() => {
|
||||||
|
|
||||||
// Import after mocks
|
// Import after mocks
|
||||||
const { MCP } = await import("../../src/mcp/index")
|
const { MCP } = await import("../../src/mcp/index")
|
||||||
|
const { Command } = await import("../../src/command/index")
|
||||||
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
|
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
|
||||||
|
|
||||||
const it = testEffect(MCP.defaultLayer)
|
const it = testEffect(Layer.merge(MCP.defaultLayer, Command.defaultLayer))
|
||||||
|
|
||||||
function statusName(status: Record<string, MCPNS.Status> | MCPNS.Status, server: string) {
|
function statusName(status: Record<string, MCPNS.Status> | MCPNS.Status, server: string) {
|
||||||
if ("status" in status) return status.status
|
if ("status" in status) return status.status
|
||||||
|
|
@ -577,6 +578,73 @@ it.instance(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.instance(
|
||||||
|
"prompt list change notifications refresh cached commands",
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const mcp = yield* MCP.Service
|
||||||
|
const command = yield* Command.Service
|
||||||
|
lastCreatedClientName = "prompt-server"
|
||||||
|
const serverState = getOrCreateClientState("prompt-server")
|
||||||
|
serverState.capabilities = { prompts: { listChanged: true } }
|
||||||
|
serverState.prompts = [{ name: "first" }]
|
||||||
|
|
||||||
|
yield* mcp.add("prompt-server", {
|
||||||
|
type: "local",
|
||||||
|
command: ["echo", "test"],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect((yield* command.list()).some((item) => item.name === "prompt-server:first")).toBe(true)
|
||||||
|
serverState.prompts = [{ name: "second" }]
|
||||||
|
|
||||||
|
const handler = Array.from(serverState.notificationHandlers.values())[0]
|
||||||
|
expect(handler).toBeDefined()
|
||||||
|
yield* Effect.promise(() => handler?.())
|
||||||
|
|
||||||
|
const commands = yield* command.list()
|
||||||
|
expect(commands.some((item) => item.name === "prompt-server:second")).toBe(true)
|
||||||
|
expect(commands.some((item) => item.name === "prompt-server:first")).toBe(false)
|
||||||
|
}),
|
||||||
|
{ config: { mcp: {} } },
|
||||||
|
)
|
||||||
|
|
||||||
|
it.instance(
|
||||||
|
"prompt notifications from replaced clients do not refresh commands",
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const mcp = yield* MCP.Service
|
||||||
|
const command = yield* Command.Service
|
||||||
|
lastCreatedClientName = "prompt-server"
|
||||||
|
const firstState = getOrCreateClientState("prompt-server")
|
||||||
|
firstState.capabilities = { prompts: { listChanged: true } }
|
||||||
|
firstState.prompts = [{ name: "first" }]
|
||||||
|
|
||||||
|
yield* mcp.add("prompt-server", {
|
||||||
|
type: "local",
|
||||||
|
command: ["echo", "test"],
|
||||||
|
})
|
||||||
|
expect((yield* command.list()).some((item) => item.name === "prompt-server:first")).toBe(true)
|
||||||
|
const staleHandler = Array.from(firstState.notificationHandlers.values())[0]
|
||||||
|
|
||||||
|
clientStates.delete("prompt-server")
|
||||||
|
const secondState = getOrCreateClientState("prompt-server")
|
||||||
|
secondState.capabilities = { prompts: { listChanged: true } }
|
||||||
|
secondState.prompts = [{ name: "second" }]
|
||||||
|
yield* mcp.add("prompt-server", {
|
||||||
|
type: "local",
|
||||||
|
command: ["echo", "test"],
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* Effect.promise(() => staleHandler?.())
|
||||||
|
expect((yield* command.list()).some((item) => item.name === "prompt-server:second")).toBe(false)
|
||||||
|
|
||||||
|
const activeHandler = Array.from(secondState.notificationHandlers.values())[0]
|
||||||
|
yield* Effect.promise(() => activeHandler?.())
|
||||||
|
expect((yield* command.list()).some((item) => item.name === "prompt-server:second")).toBe(true)
|
||||||
|
}),
|
||||||
|
{ config: { mcp: {} } },
|
||||||
|
)
|
||||||
|
|
||||||
it.instance(
|
it.instance(
|
||||||
"resources() returns resources from connected servers",
|
"resources() returns resources from connected servers",
|
||||||
() =>
|
() =>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue