|
|
@@ -1,1304 +1,560 @@
|
|
|
import path from "node:path"
|
|
|
import { pathToFileURL } from "node:url"
|
|
|
-import { expect, mock, beforeEach } from "bun:test"
|
|
|
-import { ListRootsRequestSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
|
|
|
+import { expect } from "bun:test"
|
|
|
+import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
|
|
+import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
|
|
|
+import {
|
|
|
+ GetPromptRequestSchema,
|
|
|
+ ListPromptsRequestSchema,
|
|
|
+ ListResourcesRequestSchema,
|
|
|
+ ListResourceTemplatesRequestSchema,
|
|
|
+ ListToolsRequestSchema,
|
|
|
+ ReadResourceRequestSchema,
|
|
|
+ type ServerCapabilities,
|
|
|
+ type Tool,
|
|
|
+} from "@modelcontextprotocol/sdk/types.js"
|
|
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|
|
import { Cause, Effect, Exit } from "effect"
|
|
|
import type { MCP as MCPNS } from "../../src/mcp/index"
|
|
|
-import { testEffect } from "../lib/effect"
|
|
|
+import { MCP } from "../../src/mcp/index"
|
|
|
+import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
|
|
|
import { TestInstance } from "../fixture/fixture"
|
|
|
+import { pollWithTimeout, testEffect } from "../lib/effect"
|
|
|
|
|
|
-// --- Mock infrastructure ---
|
|
|
+const it = testEffect(LayerNode.compile(MCP.node))
|
|
|
+const stdioFixture = path.join(import.meta.dir, "../fixture/mcp-lifecycle-stdio.ts")
|
|
|
+
|
|
|
+type Page<T> = { items: T[]; nextCursor?: string }
|
|
|
|
|
|
-// Per-client state for controlling mock behavior
|
|
|
-interface MockClientState {
|
|
|
- capabilities: { tools?: object; prompts?: object; resources?: object }
|
|
|
- capabilitiesShouldThrow: boolean
|
|
|
- instructions?: string
|
|
|
- tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }>
|
|
|
- listToolsCalls: number
|
|
|
- listPromptsCalls: number
|
|
|
- listResourcesCalls: number
|
|
|
- listResourceTemplatesCalls: number
|
|
|
- getPromptTimeout?: number
|
|
|
- readResourceTimeout?: number
|
|
|
- requestCalls: number
|
|
|
- listToolsShouldFail: boolean
|
|
|
- listToolsError: string
|
|
|
- listPromptsShouldFail: boolean
|
|
|
- listResourcesShouldFail: boolean
|
|
|
+interface LifecycleServerState {
|
|
|
+ tools: Tool[]
|
|
|
prompts: Array<{ name: string; description?: string }>
|
|
|
resources: Array<{ name: string; uri: string; description?: string }>
|
|
|
resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }>
|
|
|
- toolPages: Record<
|
|
|
- string,
|
|
|
- {
|
|
|
- tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }>
|
|
|
- nextCursor?: string
|
|
|
- }
|
|
|
- >
|
|
|
- promptPages: Record<string, { prompts: Array<{ name: string; description?: string }>; nextCursor?: string }>
|
|
|
- resourcePages: Record<
|
|
|
- string,
|
|
|
- { resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string }
|
|
|
- >
|
|
|
- resourceTemplatePages: Record<
|
|
|
- string,
|
|
|
- { resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }>; nextCursor?: string }
|
|
|
- >
|
|
|
- closed: boolean
|
|
|
- clientOptions?: { capabilities?: { roots?: { listChanged?: boolean } } }
|
|
|
- requestHandlers: Map<unknown, (...args: any[]) => Promise<any>>
|
|
|
- notificationHandlers: Map<unknown, (...args: any[]) => any>
|
|
|
-}
|
|
|
-
|
|
|
-const clientStates = new Map<string, MockClientState>()
|
|
|
-let lastCreatedClientName: string | undefined
|
|
|
-let connectShouldFail = false
|
|
|
-let connectShouldHang = false
|
|
|
-let connectError = "Mock transport cannot connect"
|
|
|
-// Tracks how many Client instances were created (detects leaks)
|
|
|
-let clientCreateCount = 0
|
|
|
-// Tracks how many times transport.close() is called across all mock transports
|
|
|
-let transportCloseCount = 0
|
|
|
-// Captures the opts passed to each MockStdioTransport, keyed by lastCreatedClientName
|
|
|
-const stdioOptsByName = new Map<string, any>()
|
|
|
-
|
|
|
-function getOrCreateClientState(name?: string): MockClientState {
|
|
|
- const key = name ?? "default"
|
|
|
- let state = clientStates.get(key)
|
|
|
- if (!state) {
|
|
|
- state = {
|
|
|
- capabilities: { tools: {}, prompts: {}, resources: {} },
|
|
|
- capabilitiesShouldThrow: false,
|
|
|
- tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }],
|
|
|
- listToolsCalls: 0,
|
|
|
- listPromptsCalls: 0,
|
|
|
- listResourcesCalls: 0,
|
|
|
- listResourceTemplatesCalls: 0,
|
|
|
- requestCalls: 0,
|
|
|
- listToolsShouldFail: false,
|
|
|
- listToolsError: "listTools failed",
|
|
|
- listPromptsShouldFail: false,
|
|
|
- listResourcesShouldFail: false,
|
|
|
- prompts: [],
|
|
|
- resources: [],
|
|
|
- resourceTemplates: [],
|
|
|
- toolPages: {},
|
|
|
- promptPages: {},
|
|
|
- resourcePages: {},
|
|
|
- resourceTemplatePages: {},
|
|
|
- closed: false,
|
|
|
- requestHandlers: new Map(),
|
|
|
- notificationHandlers: new Map(),
|
|
|
- }
|
|
|
- clientStates.set(key, state)
|
|
|
- }
|
|
|
- return state
|
|
|
-}
|
|
|
-
|
|
|
-// Mock transport that succeeds or fails based on connectShouldFail / connectShouldHang
|
|
|
-class MockStdioTransport {
|
|
|
- stderr: null = null
|
|
|
- pid = 12345
|
|
|
- constructor(opts: any) {
|
|
|
- if (lastCreatedClientName) stdioOptsByName.set(lastCreatedClientName, opts)
|
|
|
- }
|
|
|
- async start() {
|
|
|
- if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
|
|
|
- if (connectShouldFail) throw new Error(connectError)
|
|
|
- }
|
|
|
- async close() {
|
|
|
- transportCloseCount++
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-class MockStreamableHTTP {
|
|
|
- // oxlint-disable-next-line no-useless-constructor
|
|
|
- constructor(_url: URL, _opts?: any) {}
|
|
|
- async start() {
|
|
|
- if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
|
|
|
- if (connectShouldFail) throw new Error(connectError)
|
|
|
- }
|
|
|
- async close() {
|
|
|
- transportCloseCount++
|
|
|
- }
|
|
|
- async finishAuth() {}
|
|
|
-}
|
|
|
-
|
|
|
-class MockSSE {
|
|
|
- // oxlint-disable-next-line no-useless-constructor
|
|
|
- constructor(_url: URL, _opts?: any) {}
|
|
|
- async start() {
|
|
|
- if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
|
|
|
- if (connectShouldFail) throw new Error(connectError)
|
|
|
- }
|
|
|
- async close() {
|
|
|
- transportCloseCount++
|
|
|
- }
|
|
|
+ toolPages?: Record<string, Page<Tool>>
|
|
|
+ promptPages?: Record<string, Page<{ name: string; description?: string }>>
|
|
|
+ resourcePages?: Record<string, Page<{ name: string; uri: string; description?: string }>>
|
|
|
+ resourceTemplatePages?: Record<string, Page<{ name: string; uriTemplate: string; description?: string }>>
|
|
|
+ listToolsError?: string
|
|
|
+ requestDelay?: number
|
|
|
+ roots?: Array<{ uri: string; name?: string }>
|
|
|
+ requests: string[]
|
|
|
+ aborted: number
|
|
|
}
|
|
|
|
|
|
-void mock.module("@modelcontextprotocol/sdk/client/stdio.js", () => ({
|
|
|
- StdioClientTransport: MockStdioTransport,
|
|
|
-}))
|
|
|
-
|
|
|
-void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
|
|
- StreamableHTTPClientTransport: MockStreamableHTTP,
|
|
|
-}))
|
|
|
-
|
|
|
-void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
|
|
|
- SSEClientTransport: MockSSE,
|
|
|
-}))
|
|
|
-
|
|
|
-void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
|
|
- UnauthorizedError: class extends Error {
|
|
|
- constructor() {
|
|
|
- super("Unauthorized")
|
|
|
- }
|
|
|
- },
|
|
|
-}))
|
|
|
-
|
|
|
-// Mock Client that delegates to per-name MockClientState
|
|
|
-void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
|
|
|
- Client: class MockClient {
|
|
|
- _state!: MockClientState
|
|
|
- transport: any
|
|
|
-
|
|
|
- constructor(_info: any, options?: MockClientState["clientOptions"]) {
|
|
|
- clientCreateCount++
|
|
|
- this._state = getOrCreateClientState(lastCreatedClientName)
|
|
|
- this._state.clientOptions = options
|
|
|
- }
|
|
|
-
|
|
|
- async connect(transport: { start: () => Promise<void> }) {
|
|
|
- this.transport = transport
|
|
|
- await transport.start()
|
|
|
- // After successful connect, bind to the last-created client name
|
|
|
- this._state = getOrCreateClientState(lastCreatedClientName)
|
|
|
- }
|
|
|
-
|
|
|
- setRequestHandler(schema: unknown, handler: (...args: any[]) => Promise<any>) {
|
|
|
- this._state.requestHandlers.set(schema, handler)
|
|
|
- }
|
|
|
-
|
|
|
- setNotificationHandler(schema: unknown, handler: (...args: any[]) => any) {
|
|
|
- this._state?.notificationHandlers.set(schema, handler)
|
|
|
- }
|
|
|
-
|
|
|
- getServerCapabilities() {
|
|
|
- if (this._state?.capabilitiesShouldThrow) throw new Error("capability discovery failed")
|
|
|
- return this._state?.capabilities
|
|
|
- }
|
|
|
-
|
|
|
- getInstructions() {
|
|
|
- return this._state?.instructions
|
|
|
- }
|
|
|
-
|
|
|
- async listTools(params?: { cursor?: string }) {
|
|
|
- if (this._state) this._state.listToolsCalls++
|
|
|
- if (this._state?.listToolsShouldFail) {
|
|
|
- throw new Error(this._state.listToolsError)
|
|
|
+function lifecycleServer(input?: { capabilities?: ServerCapabilities; instructions?: string; requestRoots?: boolean }) {
|
|
|
+ const capabilities = input?.capabilities ?? { tools: {}, prompts: {}, resources: {} }
|
|
|
+ return Effect.acquireRelease(
|
|
|
+ Effect.promise(async () => {
|
|
|
+ const state: LifecycleServerState = {
|
|
|
+ tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }],
|
|
|
+ prompts: [],
|
|
|
+ resources: [],
|
|
|
+ resourceTemplates: [],
|
|
|
+ requests: [],
|
|
|
+ aborted: 0,
|
|
|
}
|
|
|
- const page = this._state?.toolPages[params === undefined ? "initial" : (params.cursor ?? "")]
|
|
|
- if (page) return page
|
|
|
- return { tools: this._state?.tools ?? [] }
|
|
|
- }
|
|
|
|
|
|
- async request(
|
|
|
- request: { method: string; params?: { cursor?: string } },
|
|
|
- schema: { parse: (value: unknown) => unknown },
|
|
|
- ) {
|
|
|
- if (this._state) this._state.requestCalls++
|
|
|
- if (request.method === "tools/list") {
|
|
|
- return schema.parse(
|
|
|
- this._state?.toolPages[request.params === undefined ? "initial" : (request.params.cursor ?? "")] ?? {
|
|
|
- tools: this._state?.tools ?? [],
|
|
|
- },
|
|
|
+ const makeProtocol = async () => {
|
|
|
+ const protocol = new Server(
|
|
|
+ { name: "mcp-lifecycle", version: "1.0.0" },
|
|
|
+ { capabilities, instructions: input?.instructions },
|
|
|
)
|
|
|
- }
|
|
|
- throw new Error(`unsupported request: ${request.method}`)
|
|
|
- }
|
|
|
-
|
|
|
- async listPrompts(params?: { cursor?: string }) {
|
|
|
- if (this._state) this._state.listPromptsCalls++
|
|
|
- if (this._state?.listPromptsShouldFail) {
|
|
|
- throw new Error("listPrompts failed")
|
|
|
- }
|
|
|
- const page = this._state?.promptPages[params === undefined ? "initial" : (params.cursor ?? "")]
|
|
|
- if (page) return page
|
|
|
- return { prompts: this._state?.prompts ?? [] }
|
|
|
- }
|
|
|
-
|
|
|
- async listResources(params?: { cursor?: string }) {
|
|
|
- if (this._state) this._state.listResourcesCalls++
|
|
|
- if (this._state?.listResourcesShouldFail) {
|
|
|
- throw new Error("listResources failed")
|
|
|
- }
|
|
|
- const page = this._state?.resourcePages[params === undefined ? "initial" : (params.cursor ?? "")]
|
|
|
- if (page) return page
|
|
|
- return { resources: this._state?.resources ?? [] }
|
|
|
- }
|
|
|
-
|
|
|
- async listResourceTemplates(params?: { cursor?: string }) {
|
|
|
- if (this._state) this._state.listResourceTemplatesCalls++
|
|
|
- const page = this._state?.resourceTemplatePages[params === undefined ? "initial" : (params.cursor ?? "")]
|
|
|
- if (page) return page
|
|
|
- return { resourceTemplates: this._state?.resourceTemplates ?? [] }
|
|
|
- }
|
|
|
-
|
|
|
- async getPrompt(_params: unknown, options?: { timeout?: number }) {
|
|
|
- if (this._state) this._state.getPromptTimeout = options?.timeout
|
|
|
- return { messages: [] }
|
|
|
- }
|
|
|
-
|
|
|
- async readResource(params: { uri: string }, options?: { timeout?: number }) {
|
|
|
- if (this._state) this._state.readResourceTimeout = options?.timeout
|
|
|
- return { contents: [{ uri: params.uri, text: "test" }] }
|
|
|
- }
|
|
|
-
|
|
|
- async close() {
|
|
|
- if (this._state) this._state.closed = true
|
|
|
- }
|
|
|
- },
|
|
|
-}))
|
|
|
-
|
|
|
-beforeEach(() => {
|
|
|
- clientStates.clear()
|
|
|
- lastCreatedClientName = undefined
|
|
|
- connectShouldFail = false
|
|
|
- connectShouldHang = false
|
|
|
- connectError = "Mock transport cannot connect"
|
|
|
- clientCreateCount = 0
|
|
|
- transportCloseCount = 0
|
|
|
-})
|
|
|
-
|
|
|
-// Import after mocks
|
|
|
-const { MCP } = await import("../../src/mcp/index")
|
|
|
-const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
|
|
|
-
|
|
|
-const it = testEffect(LayerNode.compile(MCP.node))
|
|
|
-
|
|
|
-function statusName(status: Record<string, MCPNS.Status> | MCPNS.Status, server: string) {
|
|
|
- if ("status" in status) return status.status
|
|
|
- return status[server]?.status
|
|
|
-}
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "advertises and lists the instance directory as its root",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- const { directory } = yield* TestInstance
|
|
|
- lastCreatedClientName = "roots"
|
|
|
- yield* mcp.add("roots", { type: "local", command: ["echo", "test"] })
|
|
|
-
|
|
|
- const state = getOrCreateClientState("roots")
|
|
|
- expect(state.clientOptions?.capabilities?.roots).toEqual({})
|
|
|
- expect(state.clientOptions?.capabilities?.roots?.listChanged).toBeUndefined()
|
|
|
-
|
|
|
- const handler = state.requestHandlers.get(ListRootsRequestSchema)
|
|
|
- expect(handler).toBeDefined()
|
|
|
- const result = yield* Effect.promise(() => handler?.() ?? Promise.reject(new Error("roots handler missing")))
|
|
|
- expect(result).toEqual({ roots: [{ uri: pathToFileURL(directory).href }] })
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
-)
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "local mcp cwd resolves relative paths against instance directory",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- const { directory } = yield* TestInstance
|
|
|
- lastCreatedClientName = "rel-cwd"
|
|
|
- yield* mcp.add("rel-cwd", { type: "local", command: ["echo", "test"], cwd: "plugins/sub" })
|
|
|
- expect(stdioOptsByName.get("rel-cwd")?.cwd).toBe(path.resolve(directory, "plugins/sub"))
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
-)
|
|
|
-
|
|
|
-// ========================================================================
|
|
|
-// Test: tools() are cached after connect
|
|
|
-// ========================================================================
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "tools() reuses cached tool definitions after connect",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "my-server"
|
|
|
- const serverState = getOrCreateClientState("my-server")
|
|
|
- serverState.tools = [
|
|
|
- { name: "do_thing", description: "does a thing", inputSchema: { type: "object", properties: {} } },
|
|
|
- ]
|
|
|
-
|
|
|
- // First: add the server successfully
|
|
|
- const addResult = yield* mcp.add("my-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
- expect((addResult.status as any)["my-server"]?.status ?? (addResult.status as any).status).toBe("connected")
|
|
|
-
|
|
|
- expect(serverState.listToolsCalls).toBe(1)
|
|
|
-
|
|
|
- const toolsA = yield* mcp.tools()
|
|
|
- const toolsB = yield* mcp.tools()
|
|
|
- expect(Object.keys(toolsA).length).toBeGreaterThan(0)
|
|
|
- expect(Object.keys(toolsB).length).toBeGreaterThan(0)
|
|
|
- expect(serverState.listToolsCalls).toBe(1)
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
-)
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "instructions() returns connected server instructions with tool names",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "guide-server"
|
|
|
- const serverState = getOrCreateClientState("guide-server")
|
|
|
- serverState.instructions = "Use lookup before mutate."
|
|
|
-
|
|
|
- yield* mcp.add("guide-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
+ const transport = new WebStandardStreamableHTTPServerTransport({
|
|
|
+ sessionIdGenerator: () => crypto.randomUUID(),
|
|
|
+ enableJsonResponse: true,
|
|
|
})
|
|
|
|
|
|
- expect(yield* mcp.instructions()).toContainEqual({
|
|
|
- name: "guide-server",
|
|
|
- instructions: "Use lookup before mutate.",
|
|
|
- tools: ["guide-server_test_tool"],
|
|
|
- })
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
-)
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "instructions() omits empty and disconnected server instructions",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "temporary-server"
|
|
|
- getOrCreateClientState("temporary-server").instructions = "Temporary guidance."
|
|
|
-
|
|
|
- yield* mcp.add("temporary-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
- yield* mcp.disconnect("temporary-server")
|
|
|
-
|
|
|
- lastCreatedClientName = "blank-server"
|
|
|
- getOrCreateClientState("blank-server").instructions = " "
|
|
|
-
|
|
|
- yield* mcp.add("blank-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
-
|
|
|
- const instructions = yield* mcp.instructions()
|
|
|
- expect(instructions.some((item) => item.name === "temporary-server")).toBe(false)
|
|
|
- expect(instructions.some((item) => item.name === "blank-server")).toBe(false)
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
-)
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "follows cursors when listing tools, prompts, and resources",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "paged-server"
|
|
|
- const serverState = getOrCreateClientState("paged-server")
|
|
|
- serverState.toolPages = {
|
|
|
- initial: {
|
|
|
- tools: [{ name: "tool-one", inputSchema: { type: "object", properties: {} } }],
|
|
|
- nextCursor: "tools-2",
|
|
|
- },
|
|
|
- "tools-2": { tools: [{ name: "tool-two", inputSchema: { type: "object", properties: {} } }] },
|
|
|
+ if (capabilities.tools) {
|
|
|
+ protocol.setRequestHandler(ListToolsRequestSchema, (request) => {
|
|
|
+ if (state.listToolsError) throw new Error(state.listToolsError)
|
|
|
+ const page = state.toolPages?.[request.params?.cursor ?? "initial"]
|
|
|
+ return Promise.resolve({ tools: page?.items ?? state.tools, nextCursor: page?.nextCursor })
|
|
|
+ })
|
|
|
}
|
|
|
- serverState.promptPages = {
|
|
|
- initial: { prompts: [{ name: "prompt-one" }], nextCursor: "prompts-2" },
|
|
|
- "prompts-2": { prompts: [{ name: "prompt-two" }] },
|
|
|
+ if (capabilities.prompts) {
|
|
|
+ protocol.setRequestHandler(ListPromptsRequestSchema, (request) => {
|
|
|
+ const page = state.promptPages?.[request.params?.cursor ?? "initial"]
|
|
|
+ return Promise.resolve({ prompts: page?.items ?? state.prompts, nextCursor: page?.nextCursor })
|
|
|
+ })
|
|
|
+ protocol.setRequestHandler(GetPromptRequestSchema, async () => {
|
|
|
+ if (state.requestDelay) await Bun.sleep(state.requestDelay)
|
|
|
+ return { messages: [{ role: "user", content: { type: "text", text: "prompt result" } }] }
|
|
|
+ })
|
|
|
}
|
|
|
- serverState.resourcePages = {
|
|
|
- initial: { resources: [{ name: "resource-one", uri: "test://one" }], nextCursor: "resources-2" },
|
|
|
- "resources-2": { resources: [{ name: "resource-two", uri: "test://two" }] },
|
|
|
+ if (capabilities.resources) {
|
|
|
+ protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
|
|
|
+ const page = state.resourcePages?.[request.params?.cursor ?? "initial"]
|
|
|
+ return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor })
|
|
|
+ })
|
|
|
+ protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => {
|
|
|
+ const page = state.resourceTemplatePages?.[request.params?.cursor ?? "initial"]
|
|
|
+ return Promise.resolve({
|
|
|
+ resourceTemplates: page?.items ?? state.resourceTemplates,
|
|
|
+ nextCursor: page?.nextCursor,
|
|
|
+ })
|
|
|
+ })
|
|
|
+ protocol.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
|
+ if (state.requestDelay) await Bun.sleep(state.requestDelay)
|
|
|
+ return { contents: [{ uri: request.params.uri, text: "resource result" }] }
|
|
|
+ })
|
|
|
}
|
|
|
- serverState.resourceTemplatePages = {
|
|
|
- initial: {
|
|
|
- resourceTemplates: [{ name: "template-one", uriTemplate: "test://one/{id}" }],
|
|
|
- nextCursor: "resource-templates-2",
|
|
|
- },
|
|
|
- "resource-templates-2": { resourceTemplates: [{ name: "template-two", uriTemplate: "test://two/{id}" }] },
|
|
|
- }
|
|
|
-
|
|
|
- yield* mcp.add("paged-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
-
|
|
|
- expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"])
|
|
|
- expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"])
|
|
|
- expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:test://one", "paged-server:test://two"])
|
|
|
- expect(Object.keys(yield* mcp.resourceTemplates())).toEqual([
|
|
|
- "paged-server:test://one/{id}",
|
|
|
- "paged-server:test://two/{id}",
|
|
|
- ])
|
|
|
- expect(serverState.listToolsCalls).toBe(2)
|
|
|
- expect(serverState.listPromptsCalls).toBe(2)
|
|
|
- expect(serverState.listResourcesCalls).toBe(2)
|
|
|
- expect(serverState.listResourceTemplatesCalls).toBe(2)
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
-)
|
|
|
|
|
|
-it.instance(
|
|
|
- "stops listing when a server repeats a cursor",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "looping-server"
|
|
|
- const serverState = getOrCreateClientState("looping-server")
|
|
|
- serverState.toolPages = {
|
|
|
- initial: { tools: [], nextCursor: "repeat" },
|
|
|
- repeat: { tools: [], nextCursor: "repeat" },
|
|
|
- }
|
|
|
-
|
|
|
- yield* mcp.add("looping-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
-
|
|
|
- expect(serverState.listToolsCalls).toBe(2)
|
|
|
- expect(yield* mcp.tools()).toEqual({})
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
-)
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "follows empty cursors",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "empty-cursor-server"
|
|
|
- const serverState = getOrCreateClientState("empty-cursor-server")
|
|
|
- serverState.promptPages = {
|
|
|
- initial: { prompts: [{ name: "prompt-one" }], nextCursor: "" },
|
|
|
- "": { prompts: [{ name: "prompt-two" }] },
|
|
|
+ protocol.oninitialized = () => {
|
|
|
+ if (!input?.requestRoots) return
|
|
|
+ if (!protocol.getClientCapabilities()?.roots) return
|
|
|
+ void Bun.sleep(25)
|
|
|
+ .then(() => protocol.listRoots())
|
|
|
+ .then((result) => {
|
|
|
+ state.roots = result.roots
|
|
|
+ })
|
|
|
+ .catch(() => {})
|
|
|
}
|
|
|
+ await protocol.connect(transport)
|
|
|
+ return { protocol, transport }
|
|
|
+ }
|
|
|
|
|
|
- yield* mcp.add("empty-cursor-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
-
|
|
|
- expect(Object.keys(yield* mcp.prompts())).toEqual([
|
|
|
- "empty-cursor-server:prompt-one",
|
|
|
- "empty-cursor-server:prompt-two",
|
|
|
- ])
|
|
|
- expect(serverState.listPromptsCalls).toBe(2)
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
-)
|
|
|
-
|
|
|
-// ========================================================================
|
|
|
-// Test: tool change notifications refresh the cache
|
|
|
-// ========================================================================
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "tool change notifications refresh cached tool definitions",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "status-server"
|
|
|
- const serverState = getOrCreateClientState("status-server")
|
|
|
-
|
|
|
- yield* mcp.add("status-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
-
|
|
|
- const before = yield* mcp.tools()
|
|
|
- expect(Object.keys(before).some((key) => key.includes("test_tool"))).toBe(true)
|
|
|
- expect(serverState.listToolsCalls).toBe(1)
|
|
|
-
|
|
|
- serverState.tools = [
|
|
|
- { name: "next_tool", description: "next", inputSchema: { type: "object", properties: {} } },
|
|
|
- ]
|
|
|
-
|
|
|
- const handler = serverState.notificationHandlers.get(ToolListChangedNotificationSchema)
|
|
|
- expect(handler).toBeDefined()
|
|
|
- yield* Effect.promise(() => handler?.())
|
|
|
-
|
|
|
- const after = yield* mcp.tools()
|
|
|
- expect(Object.keys(after).some((key) => key.includes("next_tool"))).toBe(true)
|
|
|
- expect(Object.keys(after).some((key) => key.includes("test_tool"))).toBe(false)
|
|
|
- expect(serverState.listToolsCalls).toBe(2)
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
-)
|
|
|
-
|
|
|
-// ========================================================================
|
|
|
-// Test: connect() / disconnect() lifecycle
|
|
|
-// ========================================================================
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "disconnect sets status to disabled and removes client",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "disc-server"
|
|
|
- getOrCreateClientState("disc-server")
|
|
|
-
|
|
|
- yield* mcp.add("disc-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
-
|
|
|
- const statusBefore = yield* mcp.status()
|
|
|
- expect(statusBefore["disc-server"]?.status).toBe("connected")
|
|
|
-
|
|
|
- yield* mcp.disconnect("disc-server")
|
|
|
-
|
|
|
- const statusAfter = yield* mcp.status()
|
|
|
- expect(statusAfter["disc-server"]?.status).toBe("disabled")
|
|
|
-
|
|
|
- const tools = yield* mcp.tools()
|
|
|
- const serverTools = Object.keys(tools).filter((k) => k.startsWith("disc-server"))
|
|
|
- expect(serverTools.length).toBe(0)
|
|
|
- }),
|
|
|
- ),
|
|
|
- {
|
|
|
- config: {
|
|
|
- mcp: {
|
|
|
- "disc-server": {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
+ let current = await makeProtocol()
|
|
|
+ const http = Bun.serve({
|
|
|
+ port: 0,
|
|
|
+ fetch(request) {
|
|
|
+ state.requests.push(request.method)
|
|
|
+ request.signal.addEventListener("abort", () => state.aborted++)
|
|
|
+ return current.transport.handleRequest(request)
|
|
|
},
|
|
|
- },
|
|
|
- },
|
|
|
- },
|
|
|
-)
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "connect() after disconnect() re-establishes the server",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "reconn-server"
|
|
|
- const serverState = getOrCreateClientState("reconn-server")
|
|
|
- serverState.tools = [
|
|
|
- { name: "my_tool", description: "a tool", inputSchema: { type: "object", properties: {} } },
|
|
|
- ]
|
|
|
-
|
|
|
- yield* mcp.add("reconn-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
-
|
|
|
- yield* mcp.disconnect("reconn-server")
|
|
|
- expect((yield* mcp.status())["reconn-server"]?.status).toBe("disabled")
|
|
|
-
|
|
|
- yield* mcp.connect("reconn-server")
|
|
|
- expect((yield* mcp.status())["reconn-server"]?.status).toBe("connected")
|
|
|
-
|
|
|
- const tools = yield* mcp.tools()
|
|
|
- expect(Object.keys(tools).some((k) => k.includes("my_tool"))).toBe(true)
|
|
|
- }),
|
|
|
- ),
|
|
|
- {
|
|
|
- config: {
|
|
|
- mcp: {
|
|
|
- "reconn-server": {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
+ })
|
|
|
+
|
|
|
+ return {
|
|
|
+ state,
|
|
|
+ url: http.url.toString(),
|
|
|
+ sendToolListChanged: () => current.protocol.sendToolListChanged(),
|
|
|
+ restart: async () => {
|
|
|
+ current = await makeProtocol()
|
|
|
},
|
|
|
- },
|
|
|
- },
|
|
|
- },
|
|
|
-)
|
|
|
-
|
|
|
-// ========================================================================
|
|
|
-// Test: add() closes existing client before replacing
|
|
|
-// ========================================================================
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "add() closes the old client when replacing a server",
|
|
|
- // Don't put the server in config — add it dynamically so we control
|
|
|
- // exactly which client instance is "first" vs "second".
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "replace-server"
|
|
|
- const firstState = getOrCreateClientState("replace-server")
|
|
|
-
|
|
|
- yield* mcp.add("replace-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
-
|
|
|
- expect(firstState.closed).toBe(false)
|
|
|
-
|
|
|
- // Create new state for second client
|
|
|
- clientStates.delete("replace-server")
|
|
|
- const secondState = getOrCreateClientState("replace-server")
|
|
|
-
|
|
|
- // Re-add should close the first client
|
|
|
- yield* mcp.add("replace-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
-
|
|
|
- expect(firstState.closed).toBe(true)
|
|
|
- expect(secondState.closed).toBe(false)
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
-)
|
|
|
-
|
|
|
-// ========================================================================
|
|
|
-// Test: state init with mixed success/failure
|
|
|
-// ========================================================================
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "init connects available servers even when one fails",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- // Set up good server
|
|
|
- const goodState = getOrCreateClientState("good-server")
|
|
|
- goodState.tools = [{ name: "good_tool", description: "works", inputSchema: { type: "object", properties: {} } }]
|
|
|
-
|
|
|
- // Set up bad server - will fail on listTools during create()
|
|
|
- const badState = getOrCreateClientState("bad-server")
|
|
|
- badState.listToolsShouldFail = true
|
|
|
-
|
|
|
- // Add good server first
|
|
|
- lastCreatedClientName = "good-server"
|
|
|
- yield* mcp.add("good-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "good"],
|
|
|
- })
|
|
|
-
|
|
|
- // Add bad server - should fail but not affect good server
|
|
|
- lastCreatedClientName = "bad-server"
|
|
|
- yield* mcp.add("bad-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "bad"],
|
|
|
- })
|
|
|
-
|
|
|
- const status = yield* mcp.status()
|
|
|
- expect(status["good-server"]?.status).toBe("connected")
|
|
|
- expect(status["bad-server"]?.status).toBe("failed")
|
|
|
-
|
|
|
- // Good server's tools should still be available
|
|
|
- const tools = yield* mcp.tools()
|
|
|
- expect(Object.keys(tools).some((k) => k.includes("good_tool"))).toBe(true)
|
|
|
- }),
|
|
|
- ),
|
|
|
- {
|
|
|
- config: {
|
|
|
- mcp: {
|
|
|
- "good-server": {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "good"],
|
|
|
- },
|
|
|
- "bad-server": {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "bad"],
|
|
|
+ close: async () => {
|
|
|
+ await current.protocol.close().catch(() => {})
|
|
|
+ http.stop(true)
|
|
|
},
|
|
|
- },
|
|
|
- },
|
|
|
- },
|
|
|
-)
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "returns failed and closes the client when SDK initialization throws",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "defective-server"
|
|
|
- const serverState = getOrCreateClientState("defective-server")
|
|
|
- serverState.capabilitiesShouldThrow = true
|
|
|
-
|
|
|
- const result = yield* mcp.add("defective-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
-
|
|
|
- expect(statusName(result.status, "defective-server")).toBe("failed")
|
|
|
- expect((yield* mcp.status())["defective-server"]).toEqual({
|
|
|
- status: "failed",
|
|
|
- error: "capability discovery failed",
|
|
|
- })
|
|
|
- expect(serverState.closed).toBe(true)
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
-)
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "falls back when MCP output schema refs fail SDK tool discovery",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "stitch-like-server"
|
|
|
- const serverState = getOrCreateClientState("stitch-like-server")
|
|
|
- serverState.listToolsShouldFail = true
|
|
|
- serverState.listToolsError = "can't resolve reference #/$defs/ScreenInstance from id #"
|
|
|
- serverState.tools = [
|
|
|
- {
|
|
|
- name: "render_screen",
|
|
|
- description: "renders a screen",
|
|
|
- inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] },
|
|
|
- outputSchema: { type: "object", properties: { screen: { $ref: "#/$defs/ScreenInstance" } } },
|
|
|
- },
|
|
|
- ]
|
|
|
-
|
|
|
- const addResult = yield* mcp.add("stitch-like-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
-
|
|
|
- expect(statusName(addResult.status, "stitch-like-server")).toBe("connected")
|
|
|
-
|
|
|
- const tools = yield* mcp.tools()
|
|
|
- expect(Object.keys(tools).some((key) => key.includes("render_screen"))).toBe(true)
|
|
|
- expect(serverState.listToolsCalls).toBe(1)
|
|
|
- expect(serverState.requestCalls).toBe(1)
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
-)
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "does not fall back for non-schema MCP tool discovery errors",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "broken-server"
|
|
|
- const serverState = getOrCreateClientState("broken-server")
|
|
|
- serverState.listToolsShouldFail = true
|
|
|
- serverState.listToolsError = "transport closed"
|
|
|
-
|
|
|
- const addResult = yield* mcp.add("broken-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
-
|
|
|
- expect(statusName(addResult.status, "broken-server")).toBe("failed")
|
|
|
- expect(serverState.listToolsCalls).toBe(1)
|
|
|
- expect(serverState.requestCalls).toBe(0)
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
-)
|
|
|
-
|
|
|
-// ========================================================================
|
|
|
-// Test: disabled server via config
|
|
|
-// ========================================================================
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "disabled server is marked as disabled without attempting connection",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- const countBefore = clientCreateCount
|
|
|
-
|
|
|
- yield* mcp.add("disabled-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- enabled: false,
|
|
|
- } as any)
|
|
|
-
|
|
|
- // No client should have been created
|
|
|
- expect(clientCreateCount).toBe(countBefore)
|
|
|
-
|
|
|
- const status = yield* mcp.status()
|
|
|
- expect(status["disabled-server"]?.status).toBe("disabled")
|
|
|
- }),
|
|
|
- ),
|
|
|
- {
|
|
|
- config: {
|
|
|
- mcp: {
|
|
|
- "disabled-server": {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- enabled: false,
|
|
|
- },
|
|
|
- },
|
|
|
- },
|
|
|
- },
|
|
|
-)
|
|
|
-
|
|
|
-// ========================================================================
|
|
|
-// Test: prompts() and resources()
|
|
|
-// ========================================================================
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "prompts() returns prompts from connected servers",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "prompt-server"
|
|
|
- const serverState = getOrCreateClientState("prompt-server")
|
|
|
- serverState.prompts = [{ name: "my-prompt", description: "A test prompt" }]
|
|
|
-
|
|
|
- yield* mcp.add("prompt-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
+ }
|
|
|
+ }),
|
|
|
+ (server) => Effect.promise(server.close),
|
|
|
+ )
|
|
|
+}
|
|
|
|
|
|
- const prompts = yield* mcp.prompts()
|
|
|
- expect(Object.keys(prompts).length).toBe(1)
|
|
|
- const key = Object.keys(prompts)[0]
|
|
|
- expect(key).toContain("prompt-server")
|
|
|
- expect(key).toContain("my-prompt")
|
|
|
- }),
|
|
|
- ),
|
|
|
- {
|
|
|
- config: {
|
|
|
- mcp: {
|
|
|
- "prompt-server": {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
+function hangingLifecycleServer() {
|
|
|
+ return Effect.acquireRelease(
|
|
|
+ Effect.promise(async () => {
|
|
|
+ const protocol = new Server({ name: "mcp-lifecycle-hanging", version: "1.0.0" }, { capabilities: { tools: {} } })
|
|
|
+ protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
|
|
|
+ const transport = new WebStandardStreamableHTTPServerTransport({
|
|
|
+ sessionIdGenerator: () => crypto.randomUUID(),
|
|
|
+ enableJsonResponse: true,
|
|
|
+ })
|
|
|
+ await protocol.connect(transport)
|
|
|
+ const requests: string[] = []
|
|
|
+ let aborted = 0
|
|
|
+ const http = Bun.serve({
|
|
|
+ port: 0,
|
|
|
+ fetch(request) {
|
|
|
+ requests.push(request.method)
|
|
|
+ request.signal.addEventListener("abort", () => aborted++)
|
|
|
+ return new Promise<Response>(() => {})
|
|
|
},
|
|
|
- },
|
|
|
- },
|
|
|
- },
|
|
|
-)
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "resources() returns resources from connected servers",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "resource-server"
|
|
|
- const serverState = getOrCreateClientState("resource-server")
|
|
|
- serverState.resources = [
|
|
|
- { name: "my-resource", uri: "file:///test.txt", description: "A test resource" },
|
|
|
- { name: "my-resource", uri: "ui://component-state", description: "A second resource with same name" },
|
|
|
- ]
|
|
|
-
|
|
|
- yield* mcp.add("resource-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
-
|
|
|
- const resources = yield* mcp.resources()
|
|
|
- expect(Object.keys(resources)).toEqual([
|
|
|
- "resource-server:file:///test.txt",
|
|
|
- "resource-server:ui://component-state",
|
|
|
- ])
|
|
|
- }),
|
|
|
- ),
|
|
|
- {
|
|
|
- config: {
|
|
|
- mcp: {
|
|
|
- "resource-server": {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
+ })
|
|
|
+ return {
|
|
|
+ requests,
|
|
|
+ aborted: () => aborted,
|
|
|
+ url: http.url.toString(),
|
|
|
+ close: async () => {
|
|
|
+ await protocol.close().catch(() => {})
|
|
|
+ http.stop(true)
|
|
|
},
|
|
|
- },
|
|
|
- },
|
|
|
- },
|
|
|
-)
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "uses per-server timeouts for prompt and resource requests",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "timeout-server"
|
|
|
- const serverState = getOrCreateClientState("timeout-server")
|
|
|
-
|
|
|
- yield* mcp.add("timeout-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- timeout: 2500,
|
|
|
- })
|
|
|
- yield* mcp.getPrompt("timeout-server", "test")
|
|
|
- yield* mcp.readResource("timeout-server", "test://resource")
|
|
|
-
|
|
|
- expect(serverState.getPromptTimeout).toBe(2500)
|
|
|
- expect(serverState.readResourceTimeout).toBe(2500)
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {}, experimental: { mcp_timeout: 5000 } } },
|
|
|
-)
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "resource-only servers connect without listing tools",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "resource-only-server"
|
|
|
- const serverState = getOrCreateClientState("resource-only-server")
|
|
|
- serverState.capabilities = { resources: {} }
|
|
|
- serverState.resources = [{ name: "docs", uri: "docs://readme" }]
|
|
|
-
|
|
|
- const result = yield* mcp.add("resource-only-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
-
|
|
|
- expect(statusName(result.status, "resource-only-server")).toBe("connected")
|
|
|
- expect(serverState.listToolsCalls).toBe(0)
|
|
|
- expect(Object.keys(yield* mcp.tools())).toHaveLength(0)
|
|
|
- expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only-server:docs://readme"])
|
|
|
- expect(serverState.listResourcesCalls).toBe(1)
|
|
|
- expect(serverState.listPromptsCalls).toBe(0)
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
-)
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "prompt-only servers connect without listing tools",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "prompt-only-server"
|
|
|
- const serverState = getOrCreateClientState("prompt-only-server")
|
|
|
- serverState.capabilities = { prompts: {} }
|
|
|
- serverState.prompts = [{ name: "review" }]
|
|
|
-
|
|
|
- const result = yield* mcp.add("prompt-only-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
+ }
|
|
|
+ }),
|
|
|
+ (server) => Effect.promise(server.close),
|
|
|
+ )
|
|
|
+}
|
|
|
|
|
|
- expect(statusName(result.status, "prompt-only-server")).toBe("connected")
|
|
|
- expect(serverState.listToolsCalls).toBe(0)
|
|
|
- expect(Object.keys(yield* mcp.tools())).toHaveLength(0)
|
|
|
- expect(Object.keys(yield* mcp.prompts())).toEqual(["prompt-only-server:review"])
|
|
|
- expect(serverState.listPromptsCalls).toBe(1)
|
|
|
- expect(serverState.listResourcesCalls).toBe(0)
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
-)
|
|
|
+function statusName(status: Record<string, MCPNS.Status> | MCPNS.Status, server: string) {
|
|
|
+ if ("status" in status) return status.status
|
|
|
+ return status[server]?.status
|
|
|
+}
|
|
|
|
|
|
-it.instance(
|
|
|
- "tools-only servers skip optional prompt and resource discovery",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "tools-only-server"
|
|
|
- const serverState = getOrCreateClientState("tools-only-server")
|
|
|
- serverState.capabilities = { tools: {} }
|
|
|
+const remote = (url: string, timeout?: number) => ({ type: "remote" as const, url, oauth: false as const, timeout })
|
|
|
|
|
|
- const result = yield* mcp.add("tools-only-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
+it.instance("advertises and lists the instance directory as its root", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const server = yield* lifecycleServer({ requestRoots: true })
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ const test = yield* TestInstance
|
|
|
+ yield* mcp.add("roots", remote(server.url))
|
|
|
|
|
|
- expect(statusName(result.status, "tools-only-server")).toBe("connected")
|
|
|
- expect(serverState.listToolsCalls).toBe(1)
|
|
|
- expect(Object.keys(yield* mcp.tools())).toEqual(["tools-only-server_test_tool"])
|
|
|
- expect(yield* mcp.prompts()).toEqual({})
|
|
|
- expect(yield* mcp.resources()).toEqual({})
|
|
|
- expect(serverState.listPromptsCalls).toBe(0)
|
|
|
- expect(serverState.listResourcesCalls).toBe(0)
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
+ const roots = yield* pollWithTimeout(
|
|
|
+ Effect.sync(() => server.state.roots),
|
|
|
+ "server did not receive roots",
|
|
|
+ )
|
|
|
+ expect(roots).toEqual([{ uri: pathToFileURL(test.directory).href }])
|
|
|
+ }),
|
|
|
)
|
|
|
|
|
|
it.instance(
|
|
|
- "prompts() skips disconnected servers",
|
|
|
+ "local mcp cwd resolves relative paths against instance directory",
|
|
|
() =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "prompt-disc-server"
|
|
|
- const serverState = getOrCreateClientState("prompt-disc-server")
|
|
|
- serverState.prompts = [{ name: "hidden-prompt", description: "Should not appear" }]
|
|
|
-
|
|
|
- yield* mcp.add("prompt-disc-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
-
|
|
|
- yield* mcp.disconnect("prompt-disc-server")
|
|
|
-
|
|
|
- const prompts = yield* mcp.prompts()
|
|
|
- expect(Object.keys(prompts).length).toBe(0)
|
|
|
- }),
|
|
|
- ),
|
|
|
- {
|
|
|
- config: {
|
|
|
- mcp: {
|
|
|
- "prompt-disc-server": {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- },
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ const test = yield* TestInstance
|
|
|
+ yield* mcp.add("rel-cwd", {
|
|
|
+ type: "local",
|
|
|
+ command: [process.execPath, stdioFixture],
|
|
|
+ cwd: "plugins/sub",
|
|
|
+ })
|
|
|
+
|
|
|
+ expect((yield* mcp.tools())["rel-cwd_current_directory"]?.def.description).toBe(
|
|
|
+ path.resolve(test.directory, "plugins/sub"),
|
|
|
+ )
|
|
|
+ }),
|
|
|
+ { init: (directory) => Effect.promise(() => Bun.$`mkdir -p ${path.join(directory, "plugins/sub")}`.quiet()) },
|
|
|
+)
|
|
|
+
|
|
|
+it.instance("tools() reuses cached definitions until a protocol notification", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const server = yield* lifecycleServer({ capabilities: { tools: { listChanged: true } } })
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ yield* mcp.add("cache-server", remote(server.url))
|
|
|
+ server.state.tools = [{ name: "next_tool", inputSchema: { type: "object", properties: {} } }]
|
|
|
+
|
|
|
+ expect(Object.keys(yield* mcp.tools())).toEqual(["cache-server_test_tool"])
|
|
|
+ yield* Effect.promise(server.sendToolListChanged)
|
|
|
+ yield* pollWithTimeout(
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const keys = Object.keys(yield* mcp.tools())
|
|
|
+ return keys.includes("cache-server_next_tool") ? keys : undefined
|
|
|
+ }),
|
|
|
+ "tool cache did not refresh",
|
|
|
+ )
|
|
|
+ expect(Object.keys(yield* mcp.tools())).toEqual(["cache-server_next_tool"])
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+it.instance("instructions() returns non-empty connected server instructions with tool names", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const guide = yield* lifecycleServer({ instructions: "Use lookup before mutate." })
|
|
|
+ const blank = yield* lifecycleServer({ instructions: " " })
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ yield* mcp.add("guide-server", remote(guide.url))
|
|
|
+ yield* mcp.add("blank-server", remote(blank.url))
|
|
|
+
|
|
|
+ expect(yield* mcp.instructions()).toEqual([
|
|
|
+ { name: "guide-server", instructions: "Use lookup before mutate.", tools: ["guide-server_test_tool"] },
|
|
|
+ ])
|
|
|
+ yield* mcp.disconnect("guide-server")
|
|
|
+ expect(yield* mcp.instructions()).toEqual([])
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+it.instance("follows cursors when listing tools, prompts, resources, and templates", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const server = yield* lifecycleServer()
|
|
|
+ server.state.toolPages = {
|
|
|
+ initial: { items: [{ name: "tool-one", inputSchema: { type: "object" } }], nextCursor: "tools-2" },
|
|
|
+ "tools-2": { items: [{ name: "tool-two", inputSchema: { type: "object" } }] },
|
|
|
+ }
|
|
|
+ server.state.promptPages = {
|
|
|
+ initial: { items: [{ name: "prompt-one" }], nextCursor: "prompts-2" },
|
|
|
+ "prompts-2": { items: [{ name: "prompt-two" }] },
|
|
|
+ }
|
|
|
+ server.state.resourcePages = {
|
|
|
+ initial: { items: [{ name: "resource-one", uri: "test://one" }], nextCursor: "resources-2" },
|
|
|
+ "resources-2": { items: [{ name: "resource-two", uri: "test://two" }] },
|
|
|
+ }
|
|
|
+ server.state.resourceTemplatePages = {
|
|
|
+ initial: { items: [{ name: "template-one", uriTemplate: "test://one/{id}" }], nextCursor: "templates-2" },
|
|
|
+ "templates-2": { items: [{ name: "template-two", uriTemplate: "test://two/{id}" }] },
|
|
|
+ }
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ yield* mcp.add("paged-server", remote(server.url))
|
|
|
+
|
|
|
+ expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"])
|
|
|
+ expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"])
|
|
|
+ expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:test://one", "paged-server:test://two"])
|
|
|
+ expect(Object.keys(yield* mcp.resourceTemplates())).toEqual([
|
|
|
+ "paged-server:test://one/{id}",
|
|
|
+ "paged-server:test://two/{id}",
|
|
|
+ ])
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+it.instance("accepts empty cursors and rejects repeated cursors", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const empty = yield* lifecycleServer({ capabilities: { prompts: {} } })
|
|
|
+ empty.state.promptPages = {
|
|
|
+ initial: { items: [{ name: "prompt-one" }], nextCursor: "" },
|
|
|
+ "": { items: [{ name: "prompt-two" }] },
|
|
|
+ }
|
|
|
+ const looping = yield* lifecycleServer({ capabilities: { tools: {} } })
|
|
|
+ looping.state.toolPages = {
|
|
|
+ initial: { items: [], nextCursor: "repeat" },
|
|
|
+ repeat: { items: [], nextCursor: "repeat" },
|
|
|
+ }
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ yield* mcp.add("empty-cursor", remote(empty.url))
|
|
|
+ const result = yield* mcp.add("looping-cursor", remote(looping.url))
|
|
|
+
|
|
|
+ expect(Object.keys(yield* mcp.prompts())).toEqual(["empty-cursor:prompt-one", "empty-cursor:prompt-two"])
|
|
|
+ expect(statusName(result.status, "looping-cursor")).toBe("failed")
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+it.instance("disconnect removes protocol data and reconnect establishes a new session", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const server = yield* lifecycleServer()
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ yield* mcp.add("reconnect-server", remote(server.url))
|
|
|
+ expect((yield* mcp.status())["reconnect-server"]?.status).toBe("connected")
|
|
|
+
|
|
|
+ yield* mcp.disconnect("reconnect-server")
|
|
|
+ expect((yield* mcp.status())["reconnect-server"]?.status).toBe("disabled")
|
|
|
+ expect(Object.keys(yield* mcp.tools())).toEqual([])
|
|
|
+ yield* pollWithTimeout(
|
|
|
+ Effect.sync(() => (server.state.aborted > 0 ? server.state.aborted : undefined)),
|
|
|
+ "disconnected HTTP session was not aborted",
|
|
|
+ )
|
|
|
+
|
|
|
+ yield* Effect.promise(server.restart)
|
|
|
+ yield* mcp.connect("reconnect-server")
|
|
|
+ expect((yield* mcp.status())["reconnect-server"]?.status).toBe("connected")
|
|
|
+ expect(Object.keys(yield* mcp.tools())).toEqual(["reconnect-server_test_tool"])
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+it.instance("add() closes the old protocol session when replacing a server", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const first = yield* lifecycleServer()
|
|
|
+ const second = yield* lifecycleServer()
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ yield* mcp.add("replace-server", remote(first.url))
|
|
|
+ yield* mcp.add("replace-server", remote(second.url))
|
|
|
+
|
|
|
+ yield* pollWithTimeout(
|
|
|
+ Effect.sync(() => (first.state.aborted > 0 ? first.state.aborted : undefined)),
|
|
|
+ "replaced HTTP session was not aborted",
|
|
|
+ )
|
|
|
+ expect(second.state.aborted).toBe(0)
|
|
|
+ expect(Object.keys(yield* mcp.tools())).toEqual(["replace-server_test_tool"])
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+it.instance("one failed server does not affect another connected server", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const good = yield* lifecycleServer()
|
|
|
+ good.state.tools = [{ name: "good_tool", inputSchema: { type: "object" } }]
|
|
|
+ const bad = yield* lifecycleServer()
|
|
|
+ bad.state.listToolsError = "listTools failed"
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ yield* mcp.add("good-server", remote(good.url))
|
|
|
+ yield* mcp.add("bad-server", remote(bad.url))
|
|
|
+
|
|
|
+ expect((yield* mcp.status())["good-server"]?.status).toBe("connected")
|
|
|
+ expect((yield* mcp.status())["bad-server"]?.status).toBe("failed")
|
|
|
+ expect(Object.keys(yield* mcp.tools())).toEqual(["good-server_good_tool"])
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+it.instance("falls back when output schema refs fail SDK tool discovery", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const server = yield* lifecycleServer({ capabilities: { tools: {} } })
|
|
|
+ server.state.tools = [
|
|
|
+ {
|
|
|
+ name: "render_screen",
|
|
|
+ inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] },
|
|
|
+ outputSchema: { type: "object", properties: { screen: { $ref: "#/$defs/ScreenInstance" } } },
|
|
|
},
|
|
|
- },
|
|
|
- },
|
|
|
-)
|
|
|
-
|
|
|
-// ========================================================================
|
|
|
-// Test: connect() on nonexistent server
|
|
|
-// ========================================================================
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "connect() on nonexistent server fails with NotFoundError",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- const exit = yield* mcp.connect("nonexistent").pipe(Effect.exit)
|
|
|
- expect(Exit.isFailure(exit)).toBe(true)
|
|
|
- if (Exit.isFailure(exit)) {
|
|
|
- expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "MCP.NotFoundError", name: "nonexistent" })
|
|
|
- }
|
|
|
- const status = yield* mcp.status()
|
|
|
- expect(status["nonexistent"]).toBeUndefined()
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
-)
|
|
|
-
|
|
|
-// ========================================================================
|
|
|
-// Test: disconnect() on nonexistent server
|
|
|
-// ========================================================================
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "disconnect() on nonexistent server fails with NotFoundError",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- const exit = yield* mcp.disconnect("nonexistent").pipe(Effect.exit)
|
|
|
- expect(Exit.isFailure(exit)).toBe(true)
|
|
|
- if (Exit.isFailure(exit)) {
|
|
|
- expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "MCP.NotFoundError", name: "nonexistent" })
|
|
|
+ ]
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ const result = yield* mcp.add("schema-server", remote(server.url))
|
|
|
+
|
|
|
+ expect(statusName(result.status, "schema-server")).toBe("connected")
|
|
|
+ expect(Object.keys(yield* mcp.tools())).toEqual(["schema-server_render_screen"])
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+it.instance("does not fall back for protocol tool discovery errors", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const server = yield* lifecycleServer({ capabilities: { tools: {} } })
|
|
|
+ server.state.listToolsError = "transport closed"
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ const result = yield* mcp.add("broken-server", remote(server.url))
|
|
|
+
|
|
|
+ expect(statusName(result.status, "broken-server")).toBe("failed")
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+it.instance("disabled server is marked disabled without opening a protocol session", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const server = yield* lifecycleServer()
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ yield* mcp.add("disabled-server", { ...remote(server.url), enabled: false })
|
|
|
+
|
|
|
+ expect((yield* mcp.status())["disabled-server"]?.status).toBe("disabled")
|
|
|
+ expect(server.state.requests).toEqual([])
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+it.instance("returns prompts and URI-keyed resources from connected servers", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const server = yield* lifecycleServer()
|
|
|
+ server.state.prompts = [{ name: "my-prompt", description: "A test prompt" }]
|
|
|
+ server.state.resources = [
|
|
|
+ { name: "same-name", uri: "file:///test.txt" },
|
|
|
+ { name: "same-name", uri: "ui://component-state" },
|
|
|
+ ]
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ yield* mcp.add("content-server", remote(server.url))
|
|
|
+
|
|
|
+ expect(Object.keys(yield* mcp.prompts())).toEqual(["content-server:my-prompt"])
|
|
|
+ expect(Object.keys(yield* mcp.resources())).toEqual([
|
|
|
+ "content-server:file:///test.txt",
|
|
|
+ "content-server:ui://component-state",
|
|
|
+ ])
|
|
|
+ yield* mcp.disconnect("content-server")
|
|
|
+ expect(yield* mcp.prompts()).toEqual({})
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+it.instance("uses per-server timeouts for prompt and resource requests", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const server = yield* lifecycleServer()
|
|
|
+ server.state.requestDelay = 200
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ yield* mcp.add("timeout-server", remote(server.url, 50))
|
|
|
+
|
|
|
+ expect(yield* mcp.getPrompt("timeout-server", "test")).toBeUndefined()
|
|
|
+ expect(yield* mcp.readResource("timeout-server", "test://resource")).toBeUndefined()
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+it.instance("connects resource-only, prompt-only, and tools-only servers", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const resources = yield* lifecycleServer({ capabilities: { resources: {} } })
|
|
|
+ resources.state.resources = [{ name: "docs", uri: "docs://readme" }]
|
|
|
+ const prompts = yield* lifecycleServer({ capabilities: { prompts: {} } })
|
|
|
+ prompts.state.prompts = [{ name: "review" }]
|
|
|
+ const tools = yield* lifecycleServer({ capabilities: { tools: {} } })
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ yield* mcp.add("resource-only", remote(resources.url))
|
|
|
+ yield* mcp.add("prompt-only", remote(prompts.url))
|
|
|
+ yield* mcp.add("tools-only", remote(tools.url))
|
|
|
+
|
|
|
+ expect(Object.keys(yield* mcp.tools())).toEqual(["tools-only_test_tool"])
|
|
|
+ expect(Object.keys(yield* mcp.prompts())).toEqual(["prompt-only:review"])
|
|
|
+ expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only:docs://readme"])
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+it.instance("connect and disconnect fail for unknown servers", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ for (const operation of [mcp.connect("missing"), mcp.disconnect("missing")]) {
|
|
|
+ const exit = yield* operation.pipe(Effect.exit)
|
|
|
+ expect(Exit.isFailure(exit)).toBe(true)
|
|
|
+ if (Exit.isFailure(exit)) {
|
|
|
+ expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "MCP.NotFoundError", name: "missing" })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ expect(yield* mcp.status()).toEqual({})
|
|
|
+ expect(yield* mcp.tools()).toEqual({})
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+it.instance("unavailable remote server is marked failed without tools", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const server = yield* Effect.acquireRelease(
|
|
|
+ Effect.sync(() => Bun.serve({ port: 0, fetch: () => new Response("unavailable", { status: 503 }) })),
|
|
|
+ (http) => Effect.promise(() => http.stop(true)),
|
|
|
+ )
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ yield* mcp.add("unavailable", remote(server.url.toString(), 500))
|
|
|
+
|
|
|
+ expect((yield* mcp.status()).unavailable?.status).toBe("failed")
|
|
|
+ expect(yield* mcp.tools()).toEqual({})
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+it.instance("tools() prefixes sanitized server and tool names", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const server = yield* lifecycleServer({ capabilities: { tools: {} } })
|
|
|
+ server.state.tools = [
|
|
|
+ { name: "tool-a", inputSchema: { type: "object" } },
|
|
|
+ { name: "tool.b", inputSchema: { type: "object" } },
|
|
|
+ ]
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ yield* mcp.add("my.special-server", remote(server.url))
|
|
|
+
|
|
|
+ expect(Object.keys(yield* mcp.tools())).toEqual(["my_special-server_tool-a", "my_special-server_tool_b"])
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+it.instance("local stdio timeout terminates the real server process", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const test = yield* TestInstance
|
|
|
+ const pidFile = path.join(test.directory, "mcp.pid")
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ const result = yield* mcp.add("hanging-stdio", {
|
|
|
+ type: "local",
|
|
|
+ command: [process.execPath, stdioFixture, "--hang"],
|
|
|
+ environment: { MCP_LIFECYCLE_PID_FILE: pidFile },
|
|
|
+ timeout: 100,
|
|
|
+ })
|
|
|
+
|
|
|
+ expect(statusName(result.status, "hanging-stdio")).toBe("failed")
|
|
|
+ const pid = yield* pollWithTimeout(
|
|
|
+ Effect.promise(async () => {
|
|
|
+ const file = Bun.file(pidFile)
|
|
|
+ return (await file.exists()) ? Number(await file.text()) : undefined
|
|
|
+ }),
|
|
|
+ "stdio fixture did not publish its pid",
|
|
|
+ )
|
|
|
+ yield* pollWithTimeout(
|
|
|
+ Effect.sync(() => {
|
|
|
+ try {
|
|
|
+ process.kill(pid, 0)
|
|
|
+ return undefined
|
|
|
+ } catch {
|
|
|
+ return true
|
|
|
}
|
|
|
}),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
+ "stdio fixture process was not terminated",
|
|
|
+ )
|
|
|
+ }),
|
|
|
)
|
|
|
|
|
|
-// ========================================================================
|
|
|
-// Test: tools() with no MCP servers configured
|
|
|
-// ========================================================================
|
|
|
+it.instance("remote timeout aborts both real HTTP transport attempts", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const server = yield* hangingLifecycleServer()
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ const result = yield* mcp.add("hanging-remote", remote(server.url, 100))
|
|
|
|
|
|
-it.instance(
|
|
|
- "tools() returns empty when no MCP servers are configured",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- const tools = yield* mcp.tools()
|
|
|
- expect(Object.keys(tools).length).toBe(0)
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
+ expect(statusName(result.status, "hanging-remote")).toBe("failed")
|
|
|
+ yield* pollWithTimeout(
|
|
|
+ Effect.sync(() => (server.aborted() >= 2 ? server.aborted() : undefined)),
|
|
|
+ "remote transport requests were not aborted",
|
|
|
+ )
|
|
|
+ expect(server.requests).toEqual(["POST", "GET"])
|
|
|
+ }),
|
|
|
)
|
|
|
|
|
|
-// ========================================================================
|
|
|
-// Test: connect failure during create()
|
|
|
-// ========================================================================
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "server that fails to connect is marked as failed",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "fail-connect"
|
|
|
- getOrCreateClientState("fail-connect")
|
|
|
- connectShouldFail = true
|
|
|
- connectError = "Connection refused"
|
|
|
-
|
|
|
- yield* mcp.add("fail-connect", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
-
|
|
|
- const status = yield* mcp.status()
|
|
|
- expect(status["fail-connect"]?.status).toBe("failed")
|
|
|
- if (status["fail-connect"]?.status === "failed") {
|
|
|
- expect(status["fail-connect"].error).toContain("Connection refused")
|
|
|
- }
|
|
|
-
|
|
|
- // No tools should be available
|
|
|
- const tools = yield* mcp.tools()
|
|
|
- expect(Object.keys(tools).length).toBe(0)
|
|
|
- }),
|
|
|
- ),
|
|
|
- {
|
|
|
- config: {
|
|
|
- mcp: {
|
|
|
- "fail-connect": {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- },
|
|
|
- },
|
|
|
- },
|
|
|
- },
|
|
|
-)
|
|
|
-
|
|
|
-// ========================================================================
|
|
|
-// Bug #5: McpOAuthCallback.cancelPending uses wrong key
|
|
|
-// ========================================================================
|
|
|
-
|
|
|
-it.live("McpOAuthCallback.cancelPending is keyed by mcpName but pendingAuths uses oauthState", () =>
|
|
|
+it.live("McpOAuthCallback.cancelPending rejects the pending callback", () =>
|
|
|
Effect.acquireUseRelease(
|
|
|
Effect.sync(() => McpOAuthCallback.waitForCallback("abc123hexstate", "my-mcp-server")),
|
|
|
(callback) =>
|
|
|
Effect.gen(function* () {
|
|
|
McpOAuthCallback.cancelPending("my-mcp-server")
|
|
|
-
|
|
|
const exit = yield* Effect.tryPromise({
|
|
|
try: () => callback,
|
|
|
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
|
|
- }).pipe(
|
|
|
- Effect.timeoutOrElse({
|
|
|
- duration: "1 second",
|
|
|
- orElse: () => Effect.fail(new Error("timed out waiting for OAuth cancellation")),
|
|
|
- }),
|
|
|
- Effect.exit,
|
|
|
- )
|
|
|
-
|
|
|
+ }).pipe(Effect.exit)
|
|
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
|
}),
|
|
|
() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore),
|
|
|
),
|
|
|
)
|
|
|
-
|
|
|
-// ========================================================================
|
|
|
-// Test: multiple tools from same server get correct name prefixes
|
|
|
-// ========================================================================
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "tools() prefixes tool names with sanitized server name",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "my.special-server"
|
|
|
- const serverState = getOrCreateClientState("my.special-server")
|
|
|
- serverState.tools = [
|
|
|
- { name: "tool-a", description: "Tool A", inputSchema: { type: "object", properties: {} } },
|
|
|
- { name: "tool.b", description: "Tool B", inputSchema: { type: "object", properties: {} } },
|
|
|
- ]
|
|
|
-
|
|
|
- yield* mcp.add("my.special-server", {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- })
|
|
|
-
|
|
|
- const tools = yield* mcp.tools()
|
|
|
- const keys = Object.keys(tools)
|
|
|
-
|
|
|
- // Server name dots should be replaced with underscores
|
|
|
- expect(keys.some((k) => k.startsWith("my_special-server_"))).toBe(true)
|
|
|
- // Tool name dots should be replaced with underscores
|
|
|
- expect(keys.some((k) => k.endsWith("tool_b"))).toBe(true)
|
|
|
- expect(keys.length).toBe(2)
|
|
|
- }),
|
|
|
- ),
|
|
|
- {
|
|
|
- config: {
|
|
|
- mcp: {
|
|
|
- "my.special-server": {
|
|
|
- type: "local",
|
|
|
- command: ["echo", "test"],
|
|
|
- },
|
|
|
- },
|
|
|
- },
|
|
|
- },
|
|
|
-)
|
|
|
-
|
|
|
-// ========================================================================
|
|
|
-// Test: transport leak — local stdio timeout (#19168)
|
|
|
-// ========================================================================
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "local stdio transport is closed when connect times out (no process leak)",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "hanging-server"
|
|
|
- getOrCreateClientState("hanging-server")
|
|
|
- connectShouldHang = true
|
|
|
-
|
|
|
- const addResult = yield* mcp.add("hanging-server", {
|
|
|
- type: "local",
|
|
|
- command: ["node", "fake.js"],
|
|
|
- timeout: 100,
|
|
|
- })
|
|
|
-
|
|
|
- const serverStatus = (addResult.status as any)["hanging-server"] ?? addResult.status
|
|
|
- expect(serverStatus.status).toBe("failed")
|
|
|
- expect(serverStatus.error).toContain("timed out")
|
|
|
- // Transport must be closed to avoid orphaned child process
|
|
|
- expect(transportCloseCount).toBeGreaterThanOrEqual(1)
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
-)
|
|
|
-
|
|
|
-// ========================================================================
|
|
|
-// Test: transport leak — remote timeout (#19168)
|
|
|
-// ========================================================================
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "remote transport is closed when connect times out",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "hanging-remote"
|
|
|
- getOrCreateClientState("hanging-remote")
|
|
|
- connectShouldHang = true
|
|
|
-
|
|
|
- const addResult = yield* mcp.add("hanging-remote", {
|
|
|
- type: "remote",
|
|
|
- url: "http://localhost:9999/mcp",
|
|
|
- timeout: 100,
|
|
|
- oauth: false,
|
|
|
- })
|
|
|
-
|
|
|
- const serverStatus = (addResult.status as any)["hanging-remote"] ?? addResult.status
|
|
|
- expect(serverStatus.status).toBe("failed")
|
|
|
- // Transport must be closed to avoid leaked HTTP connections
|
|
|
- expect(transportCloseCount).toBeGreaterThanOrEqual(1)
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
-)
|
|
|
-
|
|
|
-// ========================================================================
|
|
|
-// Test: transport leak — failed remote transports not closed (#19168)
|
|
|
-// ========================================================================
|
|
|
-
|
|
|
-it.instance(
|
|
|
- "failed remote transport is closed before trying next transport",
|
|
|
- () =>
|
|
|
- MCP.Service.use((mcp: MCPNS.Interface) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- lastCreatedClientName = "fail-remote"
|
|
|
- getOrCreateClientState("fail-remote")
|
|
|
- connectShouldFail = true
|
|
|
- connectError = "Connection refused"
|
|
|
-
|
|
|
- const addResult = yield* mcp.add("fail-remote", {
|
|
|
- type: "remote",
|
|
|
- url: "http://localhost:9999/mcp",
|
|
|
- timeout: 5000,
|
|
|
- oauth: false,
|
|
|
- })
|
|
|
-
|
|
|
- const serverStatus = (addResult.status as any)["fail-remote"] ?? addResult.status
|
|
|
- expect(serverStatus.status).toBe("failed")
|
|
|
- // Both StreamableHTTP and SSE transports should be closed
|
|
|
- expect(transportCloseCount).toBeGreaterThanOrEqual(2)
|
|
|
- }),
|
|
|
- ),
|
|
|
- { config: { mcp: {} } },
|
|
|
-)
|