lifecycle.test.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. import path from "node:path"
  2. import { pathToFileURL } from "node:url"
  3. import { expect } from "bun:test"
  4. import { Server } from "@modelcontextprotocol/sdk/server/index.js"
  5. import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
  6. import {
  7. GetPromptRequestSchema,
  8. ListPromptsRequestSchema,
  9. ListResourcesRequestSchema,
  10. ListResourceTemplatesRequestSchema,
  11. ListToolsRequestSchema,
  12. ReadResourceRequestSchema,
  13. type ServerCapabilities,
  14. type Tool,
  15. } from "@modelcontextprotocol/sdk/types.js"
  16. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  17. import { Cause, Effect, Exit } from "effect"
  18. import type { MCP as MCPNS } from "../../src/mcp/index"
  19. import { MCP } from "../../src/mcp/index"
  20. import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
  21. import { TestInstance } from "../fixture/fixture"
  22. import { pollWithTimeout, testEffect } from "../lib/effect"
  23. const it = testEffect(LayerNode.compile(MCP.node))
  24. const stdioFixture = path.join(import.meta.dir, "../fixture/mcp-lifecycle-stdio.ts")
  25. type Page<T> = { items: T[]; nextCursor?: string }
  26. interface LifecycleServerState {
  27. tools: Tool[]
  28. prompts: Array<{ name: string; description?: string }>
  29. resources: Array<{ name: string; uri: string; description?: string }>
  30. resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }>
  31. toolPages?: Record<string, Page<Tool>>
  32. promptPages?: Record<string, Page<{ name: string; description?: string }>>
  33. resourcePages?: Record<string, Page<{ name: string; uri: string; description?: string }>>
  34. resourceTemplatePages?: Record<string, Page<{ name: string; uriTemplate: string; description?: string }>>
  35. listToolsError?: string
  36. requestDelay?: number
  37. roots?: Array<{ uri: string; name?: string }>
  38. requests: string[]
  39. aborted: number
  40. }
  41. function lifecycleServer(input?: { capabilities?: ServerCapabilities; instructions?: string; requestRoots?: boolean }) {
  42. const capabilities = input?.capabilities ?? { tools: {}, prompts: {}, resources: {} }
  43. return Effect.acquireRelease(
  44. Effect.promise(async () => {
  45. const state: LifecycleServerState = {
  46. tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }],
  47. prompts: [],
  48. resources: [],
  49. resourceTemplates: [],
  50. requests: [],
  51. aborted: 0,
  52. }
  53. const makeProtocol = async () => {
  54. const protocol = new Server(
  55. { name: "mcp-lifecycle", version: "1.0.0" },
  56. { capabilities, instructions: input?.instructions },
  57. )
  58. const transport = new WebStandardStreamableHTTPServerTransport({
  59. sessionIdGenerator: () => crypto.randomUUID(),
  60. enableJsonResponse: true,
  61. })
  62. if (capabilities.tools) {
  63. protocol.setRequestHandler(ListToolsRequestSchema, (request) => {
  64. if (state.listToolsError) throw new Error(state.listToolsError)
  65. const page = state.toolPages?.[request.params?.cursor ?? "initial"]
  66. return Promise.resolve({ tools: page?.items ?? state.tools, nextCursor: page?.nextCursor })
  67. })
  68. }
  69. if (capabilities.prompts) {
  70. protocol.setRequestHandler(ListPromptsRequestSchema, (request) => {
  71. const page = state.promptPages?.[request.params?.cursor ?? "initial"]
  72. return Promise.resolve({ prompts: page?.items ?? state.prompts, nextCursor: page?.nextCursor })
  73. })
  74. protocol.setRequestHandler(GetPromptRequestSchema, async () => {
  75. if (state.requestDelay) await Bun.sleep(state.requestDelay)
  76. return { messages: [{ role: "user", content: { type: "text", text: "prompt result" } }] }
  77. })
  78. }
  79. if (capabilities.resources) {
  80. protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
  81. const page = state.resourcePages?.[request.params?.cursor ?? "initial"]
  82. return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor })
  83. })
  84. protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => {
  85. const page = state.resourceTemplatePages?.[request.params?.cursor ?? "initial"]
  86. return Promise.resolve({
  87. resourceTemplates: page?.items ?? state.resourceTemplates,
  88. nextCursor: page?.nextCursor,
  89. })
  90. })
  91. protocol.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  92. if (state.requestDelay) await Bun.sleep(state.requestDelay)
  93. return { contents: [{ uri: request.params.uri, text: "resource result" }] }
  94. })
  95. }
  96. protocol.oninitialized = () => {
  97. if (!input?.requestRoots) return
  98. if (!protocol.getClientCapabilities()?.roots) return
  99. void Bun.sleep(25)
  100. .then(() => protocol.listRoots())
  101. .then((result) => {
  102. state.roots = result.roots
  103. })
  104. .catch(() => {})
  105. }
  106. await protocol.connect(transport)
  107. return { protocol, transport }
  108. }
  109. let current = await makeProtocol()
  110. const http = Bun.serve({
  111. port: 0,
  112. fetch(request) {
  113. state.requests.push(request.method)
  114. request.signal.addEventListener("abort", () => state.aborted++)
  115. return current.transport.handleRequest(request)
  116. },
  117. })
  118. return {
  119. state,
  120. url: http.url.toString(),
  121. sendToolListChanged: () => current.protocol.sendToolListChanged(),
  122. restart: async () => {
  123. current = await makeProtocol()
  124. },
  125. close: async () => {
  126. await current.protocol.close().catch(() => {})
  127. http.stop(true)
  128. },
  129. }
  130. }),
  131. (server) => Effect.promise(server.close),
  132. )
  133. }
  134. function hangingLifecycleServer() {
  135. return Effect.acquireRelease(
  136. Effect.promise(async () => {
  137. const protocol = new Server({ name: "mcp-lifecycle-hanging", version: "1.0.0" }, { capabilities: { tools: {} } })
  138. protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
  139. const transport = new WebStandardStreamableHTTPServerTransport({
  140. sessionIdGenerator: () => crypto.randomUUID(),
  141. enableJsonResponse: true,
  142. })
  143. await protocol.connect(transport)
  144. const requests: string[] = []
  145. let aborted = 0
  146. const http = Bun.serve({
  147. port: 0,
  148. fetch(request) {
  149. requests.push(request.method)
  150. request.signal.addEventListener("abort", () => aborted++)
  151. return new Promise<Response>(() => {})
  152. },
  153. })
  154. return {
  155. requests,
  156. aborted: () => aborted,
  157. url: http.url.toString(),
  158. close: async () => {
  159. await protocol.close().catch(() => {})
  160. http.stop(true)
  161. },
  162. }
  163. }),
  164. (server) => Effect.promise(server.close),
  165. )
  166. }
  167. function statusName(status: Record<string, MCPNS.Status> | MCPNS.Status, server: string) {
  168. if ("status" in status) return status.status
  169. return status[server]?.status
  170. }
  171. const remote = (url: string, timeout?: number) => ({ type: "remote" as const, url, oauth: false as const, timeout })
  172. it.instance("advertises and lists the instance directory as its root", () =>
  173. Effect.gen(function* () {
  174. const server = yield* lifecycleServer({ requestRoots: true })
  175. const mcp = yield* MCP.Service
  176. const test = yield* TestInstance
  177. yield* mcp.add("roots", remote(server.url))
  178. const roots = yield* pollWithTimeout(
  179. Effect.sync(() => server.state.roots),
  180. "server did not receive roots",
  181. )
  182. expect(roots).toEqual([{ uri: pathToFileURL(test.directory).href }])
  183. }),
  184. )
  185. it.instance(
  186. "local mcp cwd resolves relative paths against instance directory",
  187. () =>
  188. Effect.gen(function* () {
  189. const mcp = yield* MCP.Service
  190. const test = yield* TestInstance
  191. yield* mcp.add("rel-cwd", {
  192. type: "local",
  193. command: [process.execPath, stdioFixture],
  194. cwd: "plugins/sub",
  195. })
  196. expect((yield* mcp.tools())["rel-cwd_current_directory"]?.def.description).toBe(
  197. path.resolve(test.directory, "plugins/sub"),
  198. )
  199. }),
  200. { init: (directory) => Effect.promise(() => Bun.$`mkdir -p ${path.join(directory, "plugins/sub")}`.quiet()) },
  201. )
  202. it.instance("tools() reuses cached definitions until a protocol notification", () =>
  203. Effect.gen(function* () {
  204. const server = yield* lifecycleServer({ capabilities: { tools: { listChanged: true } } })
  205. const mcp = yield* MCP.Service
  206. yield* mcp.add("cache-server", remote(server.url))
  207. server.state.tools = [{ name: "next_tool", inputSchema: { type: "object", properties: {} } }]
  208. expect(Object.keys(yield* mcp.tools())).toEqual(["cache-server_test_tool"])
  209. yield* Effect.promise(server.sendToolListChanged)
  210. yield* pollWithTimeout(
  211. Effect.gen(function* () {
  212. const keys = Object.keys(yield* mcp.tools())
  213. return keys.includes("cache-server_next_tool") ? keys : undefined
  214. }),
  215. "tool cache did not refresh",
  216. )
  217. expect(Object.keys(yield* mcp.tools())).toEqual(["cache-server_next_tool"])
  218. }),
  219. )
  220. it.instance("instructions() returns non-empty connected server instructions with tool names", () =>
  221. Effect.gen(function* () {
  222. const guide = yield* lifecycleServer({ instructions: "Use lookup before mutate." })
  223. const blank = yield* lifecycleServer({ instructions: " " })
  224. const mcp = yield* MCP.Service
  225. yield* mcp.add("guide-server", remote(guide.url))
  226. yield* mcp.add("blank-server", remote(blank.url))
  227. expect(yield* mcp.instructions()).toEqual([
  228. { name: "guide-server", instructions: "Use lookup before mutate.", tools: ["guide-server_test_tool"] },
  229. ])
  230. yield* mcp.disconnect("guide-server")
  231. expect(yield* mcp.instructions()).toEqual([])
  232. }),
  233. )
  234. it.instance("follows cursors when listing tools, prompts, resources, and templates", () =>
  235. Effect.gen(function* () {
  236. const server = yield* lifecycleServer()
  237. server.state.toolPages = {
  238. initial: { items: [{ name: "tool-one", inputSchema: { type: "object" } }], nextCursor: "tools-2" },
  239. "tools-2": { items: [{ name: "tool-two", inputSchema: { type: "object" } }] },
  240. }
  241. server.state.promptPages = {
  242. initial: { items: [{ name: "prompt-one" }], nextCursor: "prompts-2" },
  243. "prompts-2": { items: [{ name: "prompt-two" }] },
  244. }
  245. server.state.resourcePages = {
  246. initial: { items: [{ name: "resource-one", uri: "test://one" }], nextCursor: "resources-2" },
  247. "resources-2": { items: [{ name: "resource-two", uri: "test://two" }] },
  248. }
  249. server.state.resourceTemplatePages = {
  250. initial: { items: [{ name: "template-one", uriTemplate: "test://one/{id}" }], nextCursor: "templates-2" },
  251. "templates-2": { items: [{ name: "template-two", uriTemplate: "test://two/{id}" }] },
  252. }
  253. const mcp = yield* MCP.Service
  254. yield* mcp.add("paged-server", remote(server.url))
  255. expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"])
  256. expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"])
  257. expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:test://one", "paged-server:test://two"])
  258. expect(Object.keys(yield* mcp.resourceTemplates())).toEqual([
  259. "paged-server:test://one/{id}",
  260. "paged-server:test://two/{id}",
  261. ])
  262. }),
  263. )
  264. it.instance("accepts empty cursors and rejects repeated cursors", () =>
  265. Effect.gen(function* () {
  266. const empty = yield* lifecycleServer({ capabilities: { prompts: {} } })
  267. empty.state.promptPages = {
  268. initial: { items: [{ name: "prompt-one" }], nextCursor: "" },
  269. "": { items: [{ name: "prompt-two" }] },
  270. }
  271. const looping = yield* lifecycleServer({ capabilities: { tools: {} } })
  272. looping.state.toolPages = {
  273. initial: { items: [], nextCursor: "repeat" },
  274. repeat: { items: [], nextCursor: "repeat" },
  275. }
  276. const mcp = yield* MCP.Service
  277. yield* mcp.add("empty-cursor", remote(empty.url))
  278. const result = yield* mcp.add("looping-cursor", remote(looping.url))
  279. expect(Object.keys(yield* mcp.prompts())).toEqual(["empty-cursor:prompt-one", "empty-cursor:prompt-two"])
  280. expect(statusName(result.status, "looping-cursor")).toBe("failed")
  281. }),
  282. )
  283. it.instance("disconnect removes protocol data and reconnect establishes a new session", () =>
  284. Effect.gen(function* () {
  285. const server = yield* lifecycleServer()
  286. const mcp = yield* MCP.Service
  287. yield* mcp.add("reconnect-server", remote(server.url))
  288. expect((yield* mcp.status())["reconnect-server"]?.status).toBe("connected")
  289. yield* mcp.disconnect("reconnect-server")
  290. expect((yield* mcp.status())["reconnect-server"]?.status).toBe("disabled")
  291. expect(Object.keys(yield* mcp.tools())).toEqual([])
  292. yield* pollWithTimeout(
  293. Effect.sync(() => (server.state.aborted > 0 ? server.state.aborted : undefined)),
  294. "disconnected HTTP session was not aborted",
  295. )
  296. yield* Effect.promise(server.restart)
  297. yield* mcp.connect("reconnect-server")
  298. expect((yield* mcp.status())["reconnect-server"]?.status).toBe("connected")
  299. expect(Object.keys(yield* mcp.tools())).toEqual(["reconnect-server_test_tool"])
  300. }),
  301. )
  302. it.instance("add() closes the old protocol session when replacing a server", () =>
  303. Effect.gen(function* () {
  304. const first = yield* lifecycleServer()
  305. const second = yield* lifecycleServer()
  306. const mcp = yield* MCP.Service
  307. yield* mcp.add("replace-server", remote(first.url))
  308. yield* mcp.add("replace-server", remote(second.url))
  309. yield* pollWithTimeout(
  310. Effect.sync(() => (first.state.aborted > 0 ? first.state.aborted : undefined)),
  311. "replaced HTTP session was not aborted",
  312. )
  313. expect(second.state.aborted).toBe(0)
  314. expect(Object.keys(yield* mcp.tools())).toEqual(["replace-server_test_tool"])
  315. }),
  316. )
  317. it.instance("one failed server does not affect another connected server", () =>
  318. Effect.gen(function* () {
  319. const good = yield* lifecycleServer()
  320. good.state.tools = [{ name: "good_tool", inputSchema: { type: "object" } }]
  321. const bad = yield* lifecycleServer()
  322. bad.state.listToolsError = "listTools failed"
  323. const mcp = yield* MCP.Service
  324. yield* mcp.add("good-server", remote(good.url))
  325. yield* mcp.add("bad-server", remote(bad.url))
  326. expect((yield* mcp.status())["good-server"]?.status).toBe("connected")
  327. expect((yield* mcp.status())["bad-server"]?.status).toBe("failed")
  328. expect(Object.keys(yield* mcp.tools())).toEqual(["good-server_good_tool"])
  329. }),
  330. )
  331. it.instance("falls back when output schema refs fail SDK tool discovery", () =>
  332. Effect.gen(function* () {
  333. const server = yield* lifecycleServer({ capabilities: { tools: {} } })
  334. server.state.tools = [
  335. {
  336. name: "render_screen",
  337. inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] },
  338. outputSchema: { type: "object", properties: { screen: { $ref: "#/$defs/ScreenInstance" } } },
  339. },
  340. ]
  341. const mcp = yield* MCP.Service
  342. const result = yield* mcp.add("schema-server", remote(server.url))
  343. expect(statusName(result.status, "schema-server")).toBe("connected")
  344. expect(Object.keys(yield* mcp.tools())).toEqual(["schema-server_render_screen"])
  345. }),
  346. )
  347. it.instance("does not fall back for protocol tool discovery errors", () =>
  348. Effect.gen(function* () {
  349. const server = yield* lifecycleServer({ capabilities: { tools: {} } })
  350. server.state.listToolsError = "transport closed"
  351. const mcp = yield* MCP.Service
  352. const result = yield* mcp.add("broken-server", remote(server.url))
  353. expect(statusName(result.status, "broken-server")).toBe("failed")
  354. }),
  355. )
  356. it.instance("disabled server is marked disabled without opening a protocol session", () =>
  357. Effect.gen(function* () {
  358. const server = yield* lifecycleServer()
  359. const mcp = yield* MCP.Service
  360. yield* mcp.add("disabled-server", { ...remote(server.url), enabled: false })
  361. expect((yield* mcp.status())["disabled-server"]?.status).toBe("disabled")
  362. expect(server.state.requests).toEqual([])
  363. }),
  364. )
  365. it.instance("returns prompts and URI-keyed resources from connected servers", () =>
  366. Effect.gen(function* () {
  367. const server = yield* lifecycleServer()
  368. server.state.prompts = [{ name: "my-prompt", description: "A test prompt" }]
  369. server.state.resources = [
  370. { name: "same-name", uri: "file:///test.txt" },
  371. { name: "same-name", uri: "ui://component-state" },
  372. ]
  373. const mcp = yield* MCP.Service
  374. yield* mcp.add("content-server", remote(server.url))
  375. expect(Object.keys(yield* mcp.prompts())).toEqual(["content-server:my-prompt"])
  376. expect(Object.keys(yield* mcp.resources())).toEqual([
  377. "content-server:file:///test.txt",
  378. "content-server:ui://component-state",
  379. ])
  380. yield* mcp.disconnect("content-server")
  381. expect(yield* mcp.prompts()).toEqual({})
  382. }),
  383. )
  384. it.instance("uses per-server timeouts for prompt and resource requests", () =>
  385. Effect.gen(function* () {
  386. const server = yield* lifecycleServer()
  387. server.state.requestDelay = 200
  388. const mcp = yield* MCP.Service
  389. yield* mcp.add("timeout-server", remote(server.url, 50))
  390. expect(yield* mcp.getPrompt("timeout-server", "test")).toBeUndefined()
  391. expect(yield* mcp.readResource("timeout-server", "test://resource")).toBeUndefined()
  392. }),
  393. )
  394. it.instance("connects resource-only, prompt-only, and tools-only servers", () =>
  395. Effect.gen(function* () {
  396. const resources = yield* lifecycleServer({ capabilities: { resources: {} } })
  397. resources.state.resources = [{ name: "docs", uri: "docs://readme" }]
  398. const prompts = yield* lifecycleServer({ capabilities: { prompts: {} } })
  399. prompts.state.prompts = [{ name: "review" }]
  400. const tools = yield* lifecycleServer({ capabilities: { tools: {} } })
  401. const mcp = yield* MCP.Service
  402. yield* mcp.add("resource-only", remote(resources.url))
  403. yield* mcp.add("prompt-only", remote(prompts.url))
  404. yield* mcp.add("tools-only", remote(tools.url))
  405. expect(Object.keys(yield* mcp.tools())).toEqual(["tools-only_test_tool"])
  406. expect(Object.keys(yield* mcp.prompts())).toEqual(["prompt-only:review"])
  407. expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only:docs://readme"])
  408. }),
  409. )
  410. it.instance("connect and disconnect fail for unknown servers", () =>
  411. Effect.gen(function* () {
  412. const mcp = yield* MCP.Service
  413. for (const operation of [mcp.connect("missing"), mcp.disconnect("missing")]) {
  414. const exit = yield* operation.pipe(Effect.exit)
  415. expect(Exit.isFailure(exit)).toBe(true)
  416. if (Exit.isFailure(exit)) {
  417. expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "MCP.NotFoundError", name: "missing" })
  418. }
  419. }
  420. expect(yield* mcp.status()).toEqual({})
  421. expect(yield* mcp.tools()).toEqual({})
  422. }),
  423. )
  424. it.instance("unavailable remote server is marked failed without tools", () =>
  425. Effect.gen(function* () {
  426. const server = yield* Effect.acquireRelease(
  427. Effect.sync(() => Bun.serve({ port: 0, fetch: () => new Response("unavailable", { status: 503 }) })),
  428. (http) => Effect.promise(() => http.stop(true)),
  429. )
  430. const mcp = yield* MCP.Service
  431. yield* mcp.add("unavailable", remote(server.url.toString(), 500))
  432. expect((yield* mcp.status()).unavailable?.status).toBe("failed")
  433. expect(yield* mcp.tools()).toEqual({})
  434. }),
  435. )
  436. it.instance("tools() prefixes sanitized server and tool names", () =>
  437. Effect.gen(function* () {
  438. const server = yield* lifecycleServer({ capabilities: { tools: {} } })
  439. server.state.tools = [
  440. { name: "tool-a", inputSchema: { type: "object" } },
  441. { name: "tool.b", inputSchema: { type: "object" } },
  442. ]
  443. const mcp = yield* MCP.Service
  444. yield* mcp.add("my.special-server", remote(server.url))
  445. expect(Object.keys(yield* mcp.tools())).toEqual(["my_special-server_tool-a", "my_special-server_tool_b"])
  446. }),
  447. )
  448. it.instance("local stdio timeout terminates the real server process", () =>
  449. Effect.gen(function* () {
  450. const test = yield* TestInstance
  451. const pidFile = path.join(test.directory, "mcp.pid")
  452. const mcp = yield* MCP.Service
  453. const result = yield* mcp.add("hanging-stdio", {
  454. type: "local",
  455. command: [process.execPath, stdioFixture, "--hang"],
  456. environment: { MCP_LIFECYCLE_PID_FILE: pidFile },
  457. timeout: 100,
  458. })
  459. expect(statusName(result.status, "hanging-stdio")).toBe("failed")
  460. const pid = yield* pollWithTimeout(
  461. Effect.promise(async () => {
  462. const file = Bun.file(pidFile)
  463. return (await file.exists()) ? Number(await file.text()) : undefined
  464. }),
  465. "stdio fixture did not publish its pid",
  466. )
  467. yield* pollWithTimeout(
  468. Effect.sync(() => {
  469. try {
  470. process.kill(pid, 0)
  471. return undefined
  472. } catch {
  473. return true
  474. }
  475. }),
  476. "stdio fixture process was not terminated",
  477. )
  478. }),
  479. )
  480. it.instance("remote timeout aborts both real HTTP transport attempts", () =>
  481. Effect.gen(function* () {
  482. const server = yield* hangingLifecycleServer()
  483. const mcp = yield* MCP.Service
  484. const result = yield* mcp.add("hanging-remote", remote(server.url, 100))
  485. expect(statusName(result.status, "hanging-remote")).toBe("failed")
  486. yield* pollWithTimeout(
  487. Effect.sync(() => (server.aborted() >= 2 ? server.aborted() : undefined)),
  488. "remote transport requests were not aborted",
  489. )
  490. expect(server.requests).toEqual(["POST", "GET"])
  491. }),
  492. )
  493. it.live("McpOAuthCallback.cancelPending rejects the pending callback", () =>
  494. Effect.acquireUseRelease(
  495. Effect.sync(() => McpOAuthCallback.waitForCallback("abc123hexstate", "my-mcp-server")),
  496. (callback) =>
  497. Effect.gen(function* () {
  498. McpOAuthCallback.cancelPending("my-mcp-server")
  499. const exit = yield* Effect.tryPromise({
  500. try: () => callback,
  501. catch: (error) => (error instanceof Error ? error : new Error(String(error))),
  502. }).pipe(Effect.exit)
  503. expect(Exit.isFailure(exit)).toBe(true)
  504. }),
  505. () => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore),
  506. ),
  507. )