561 lines
22 KiB
TypeScript
561 lines
22 KiB
TypeScript
import path from "node:path"
|
|
import { pathToFileURL } from "node:url"
|
|
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 { MCP } from "../../src/mcp/index"
|
|
import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
|
|
import { TestInstance } from "../fixture/fixture"
|
|
import { pollWithTimeout, testEffect } from "../lib/effect"
|
|
|
|
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 }
|
|
|
|
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, 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
|
|
}
|
|
|
|
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 makeProtocol = async () => {
|
|
const protocol = new Server(
|
|
{ name: "mcp-lifecycle", version: "1.0.0" },
|
|
{ capabilities, instructions: input?.instructions },
|
|
)
|
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
sessionIdGenerator: () => crypto.randomUUID(),
|
|
enableJsonResponse: true,
|
|
})
|
|
|
|
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 })
|
|
})
|
|
}
|
|
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" } }] }
|
|
})
|
|
}
|
|
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" }] }
|
|
})
|
|
}
|
|
|
|
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 }
|
|
}
|
|
|
|
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)
|
|
},
|
|
})
|
|
|
|
return {
|
|
state,
|
|
url: http.url.toString(),
|
|
sendToolListChanged: () => current.protocol.sendToolListChanged(),
|
|
restart: async () => {
|
|
current = await makeProtocol()
|
|
},
|
|
close: async () => {
|
|
await current.protocol.close().catch(() => {})
|
|
http.stop(true)
|
|
},
|
|
}
|
|
}),
|
|
(server) => Effect.promise(server.close),
|
|
)
|
|
}
|
|
|
|
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>(() => {})
|
|
},
|
|
})
|
|
return {
|
|
requests,
|
|
aborted: () => aborted,
|
|
url: http.url.toString(),
|
|
close: async () => {
|
|
await protocol.close().catch(() => {})
|
|
http.stop(true)
|
|
},
|
|
}
|
|
}),
|
|
(server) => Effect.promise(server.close),
|
|
)
|
|
}
|
|
|
|
function statusName(status: Record<string, MCPNS.Status> | MCPNS.Status, server: string) {
|
|
if ("status" in status) return status.status
|
|
return status[server]?.status
|
|
}
|
|
|
|
const remote = (url: string, timeout?: number) => ({ type: "remote" as const, url, oauth: false as const, timeout })
|
|
|
|
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))
|
|
|
|
const roots = yield* pollWithTimeout(
|
|
Effect.sync(() => server.state.roots),
|
|
"server did not receive roots",
|
|
)
|
|
expect(roots).toEqual([{ uri: pathToFileURL(test.directory).href }])
|
|
}),
|
|
)
|
|
|
|
it.instance(
|
|
"local mcp cwd resolves relative paths against instance directory",
|
|
() =>
|
|
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" } } },
|
|
},
|
|
]
|
|
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
|
|
}
|
|
}),
|
|
"stdio fixture process was not terminated",
|
|
)
|
|
}),
|
|
)
|
|
|
|
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))
|
|
|
|
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"])
|
|
}),
|
|
)
|
|
|
|
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.exit)
|
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
}),
|
|
() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore),
|
|
),
|
|
)
|