mcp.test.ts 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967
  1. import path from "node:path"
  2. import { describe, expect, test } from "bun:test"
  3. import { Client } from "@modelcontextprotocol/sdk/client/index.js"
  4. import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
  5. import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
  6. import { Server } from "@modelcontextprotocol/sdk/server/index.js"
  7. import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
  8. import {
  9. CallToolRequestSchema,
  10. ListResourcesRequestSchema,
  11. ListResourceTemplatesRequestSchema,
  12. ListToolsRequestSchema,
  13. ReadResourceRequestSchema,
  14. } from "@modelcontextprotocol/sdk/types.js"
  15. import { ConfigMCP } from "@opencode-ai/core/config/mcp"
  16. import { Config } from "@opencode-ai/core/config"
  17. import { Credential } from "@opencode-ai/core/credential"
  18. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  19. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  20. import { Bus } from "@opencode-ai/core/bus"
  21. import { Event } from "@opencode-ai/schema/event"
  22. import { Form } from "@opencode-ai/core/form"
  23. import { Integration } from "@opencode-ai/core/integration"
  24. import { Location } from "@opencode-ai/core/location"
  25. import { MCP } from "@opencode-ai/core/mcp/index"
  26. import { MCPClient } from "@opencode-ai/core/mcp/client"
  27. import { Permission } from "@opencode-ai/core/permission"
  28. import { AbsolutePath } from "@opencode-ai/core/schema"
  29. import { Session } from "@opencode-ai/core/session"
  30. import { McpTool } from "@opencode-ai/core/tool/mcp"
  31. import { Tool } from "@opencode-ai/core/tool"
  32. import { Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
  33. import { Image } from "@opencode-ai/core/image"
  34. import { testEffect } from "./lib/effect"
  35. import { imagePassthrough } from "./lib/image"
  36. import { location } from "./fixture/location"
  37. import { executeTool, toolDefinitions, toolIdentity, waitForCodeModeTool, waitForTool } from "./lib/tool"
  38. let assertion: Deferred.Deferred<Permission.AssertInput> | undefined
  39. let decision: Effect.Effect<void, Permission.Error> = Effect.void
  40. let calls = 0
  41. type ResourcePage = {
  42. items: Array<{ name: string; uri: string; description?: string; mimeType?: string }>
  43. nextCursor?: string
  44. }
  45. type ResourceTemplatePage = {
  46. items: Array<{ name: string; uriTemplate: string; description?: string; mimeType?: string }>
  47. nextCursor?: string
  48. }
  49. function resourceServer(
  50. input: { resources?: boolean; listChanged?: boolean; emptyElicitation?: boolean; urlElicitation?: boolean } = {},
  51. ) {
  52. return Effect.acquireRelease(
  53. Effect.promise(async () => {
  54. const state = {
  55. resources: [] as ResourcePage["items"],
  56. templates: [] as ResourceTemplatePage["items"],
  57. resourcePages: undefined as Record<string, ResourcePage> | undefined,
  58. templatePages: undefined as Record<string, ResourceTemplatePage> | undefined,
  59. contents: [
  60. { uri: "docs://readme", text: "hello", mimeType: "text/plain" },
  61. { uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
  62. ] as Array<{ uri: string; text: string; mimeType?: string } | { uri: string; blob: string; mimeType?: string }>,
  63. resourceLists: 0,
  64. templateLists: 0,
  65. }
  66. const protocol = new Server(
  67. { name: "mcp-resources", version: "1.0.0" },
  68. {
  69. capabilities: {
  70. tools: {},
  71. ...(input.resources === false ? {} : { resources: { listChanged: input.listChanged } }),
  72. },
  73. },
  74. )
  75. protocol.setRequestHandler(ListToolsRequestSchema, () =>
  76. Promise.resolve({
  77. tools: input.emptyElicitation
  78. ? [{ name: "empty-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
  79. : input.urlElicitation
  80. ? [{ name: "url-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
  81. : [],
  82. }),
  83. )
  84. if (input.emptyElicitation) {
  85. protocol.setRequestHandler(CallToolRequestSchema, async () => {
  86. const result = await protocol.elicitInput({
  87. mode: "form",
  88. message: "Confirm",
  89. requestedSchema: { type: "object", properties: {} },
  90. })
  91. return {
  92. content: [{ type: "text", text: JSON.stringify(result) }],
  93. structuredContent: result,
  94. }
  95. })
  96. }
  97. if (input.urlElicitation) {
  98. protocol.setRequestHandler(CallToolRequestSchema, async () => {
  99. const result = await protocol.elicitInput({
  100. mode: "url",
  101. message: "Authorize access",
  102. url: "https://example.com/authorize",
  103. elicitationId: "elicitation-test",
  104. })
  105. return {
  106. content: [{ type: "text", text: JSON.stringify(result) }],
  107. structuredContent: result,
  108. }
  109. })
  110. }
  111. if (input.resources !== false) {
  112. protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
  113. state.resourceLists += 1
  114. const page = state.resourcePages?.[request.params?.cursor ?? "initial"]
  115. return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor })
  116. })
  117. protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => {
  118. state.templateLists += 1
  119. const page = state.templatePages?.[request.params?.cursor ?? "initial"]
  120. return Promise.resolve({ resourceTemplates: page?.items ?? state.templates, nextCursor: page?.nextCursor })
  121. })
  122. protocol.setRequestHandler(ReadResourceRequestSchema, () => Promise.resolve({ contents: state.contents }))
  123. }
  124. const transport = new WebStandardStreamableHTTPServerTransport({
  125. sessionIdGenerator: () => crypto.randomUUID(),
  126. enableJsonResponse: true,
  127. })
  128. await protocol.connect(transport)
  129. const http = Bun.serve({
  130. port: 0,
  131. fetch: (request) => transport.handleRequest(request),
  132. })
  133. return {
  134. state,
  135. url: http.url.toString(),
  136. clientVersion: () => protocol.getClientVersion(),
  137. sendResourceListChanged: () => protocol.sendResourceListChanged(),
  138. completeElicitation: () => protocol.createElicitationCompletionNotifier("elicitation-test")(),
  139. close: async () => {
  140. await protocol.close().catch(() => {})
  141. await http.stop(true)
  142. },
  143. }
  144. }),
  145. (server) => Effect.promise(server.close),
  146. )
  147. }
  148. function resourceMcpLayer(
  149. server: string | typeof ConfigMCP.Server.Type,
  150. onFormCreated?: (form: Form.Info) => Effect.Effect<void>,
  151. options?: MCP.Options,
  152. ) {
  153. const directory = AbsolutePath.make(import.meta.dir)
  154. const unusedIntegration = () => Effect.die("unused integration service")
  155. return MCP.layer(options).pipe(
  156. Layer.provideMerge(Form.layer),
  157. Layer.provide(
  158. Layer.mergeAll(
  159. Config.testLayer([
  160. new Config.Document({
  161. type: "document",
  162. info: new Config.Info({
  163. mcp: new ConfigMCP.Info({
  164. servers: {
  165. resources:
  166. typeof server === "string"
  167. ? new ConfigMCP.Remote({ type: "remote", url: server, oauth: false })
  168. : server,
  169. },
  170. }),
  171. }),
  172. }),
  173. ]),
  174. Layer.succeed(Location.Service, Location.Service.of(location({ directory }))),
  175. Layer.mock(Bus.Service, {
  176. subscribe: () => Stream.never,
  177. publish: (definition, data) => {
  178. const event = {
  179. id: Event.ID.create(),
  180. type: definition.type,
  181. data,
  182. } as Event.Payload<typeof definition>
  183. if (event.type !== Form.Event.Created.type || !onFormCreated) return Effect.succeed(event)
  184. return onFormCreated(Schema.decodeUnknownSync(Form.Event.Created.data)(data).form).pipe(Effect.as(event))
  185. },
  186. }),
  187. Layer.mock(Integration.Service, {
  188. connection: {
  189. active: unusedIntegration,
  190. resolve: unusedIntegration,
  191. key: unusedIntegration,
  192. update: unusedIntegration,
  193. remove: unusedIntegration,
  194. },
  195. oauth: {
  196. connect: unusedIntegration,
  197. status: unusedIntegration,
  198. complete: unusedIntegration,
  199. cancel: unusedIntegration,
  200. },
  201. command: {
  202. connect: unusedIntegration,
  203. status: unusedIntegration,
  204. cancel: unusedIntegration,
  205. },
  206. }),
  207. Layer.mock(Credential.Service, {}),
  208. ),
  209. ),
  210. )
  211. }
  212. const mcp = Layer.mock(MCP.Service, {
  213. tools: () =>
  214. Effect.succeed([
  215. new MCP.Tool({
  216. server: MCP.ServerName.make("demo"),
  217. name: "search",
  218. description: "Search",
  219. inputSchema: { type: "object", properties: {} },
  220. outputSchema: {
  221. type: "object",
  222. properties: { ok: { type: "boolean" } },
  223. required: ["ok"],
  224. },
  225. }),
  226. new MCP.Tool({
  227. server: MCP.ServerName.make("direct"),
  228. name: "lookup",
  229. codemode: false,
  230. description: "Lookup",
  231. inputSchema: { type: "object", properties: {} },
  232. }),
  233. new MCP.Tool({
  234. server: MCP.ServerName.make("direct"),
  235. name: "fail",
  236. codemode: false,
  237. description: "Always fails",
  238. inputSchema: { type: "object", properties: {} },
  239. }),
  240. new MCP.Tool({
  241. server: MCP.ServerName.make("direct"),
  242. name: "media",
  243. codemode: false,
  244. description: "Returns text and an image",
  245. inputSchema: { type: "object", properties: {} },
  246. }),
  247. ]),
  248. callTool: (input) =>
  249. Effect.sync(() => {
  250. calls += 1
  251. if (input.name === "fail")
  252. return new MCP.ToolResult({
  253. server: MCP.ServerName.make(input.server),
  254. tool: input.name,
  255. isError: true,
  256. content: [{ type: "text", text: "search index unavailable" }],
  257. })
  258. if (input.name === "media")
  259. return new MCP.ToolResult({
  260. server: MCP.ServerName.make(input.server),
  261. tool: input.name,
  262. isError: false,
  263. content: [
  264. { type: "text", text: "rendered chart" },
  265. { type: "media", data: "aGVsbG8=", mimeType: "image/png" },
  266. ],
  267. })
  268. return new MCP.ToolResult({
  269. server: MCP.ServerName.make(input.server),
  270. tool: input.name,
  271. isError: false,
  272. structured: { ok: true },
  273. content: [],
  274. })
  275. }),
  276. })
  277. const permissions = Layer.mock(Permission.Service, {
  278. assert: (input) =>
  279. Effect.gen(function* () {
  280. if (!assertion) return yield* Effect.die("Permission test is not initialized")
  281. yield* Deferred.succeed(assertion, input)
  282. yield* decision
  283. }),
  284. })
  285. const events = Layer.mock(Bus.Service, { subscribe: () => Stream.never })
  286. const it = testEffect(
  287. AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node]), [
  288. [MCP.node, mcp],
  289. [Permission.node, permissions],
  290. [Bus.node, events],
  291. [Image.node, imagePassthrough],
  292. ]),
  293. )
  294. describe("MCP errors", () => {
  295. test("expose useful messages", () => {
  296. expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
  297. expect(
  298. new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message,
  299. ).toBe("failed")
  300. expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
  301. expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
  302. })
  303. })
  304. test("MCP tool names match V1 sanitization", () => {
  305. expect(McpTool.namespace("context 7")).toBe("context_7")
  306. expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
  307. })
  308. test("preserves output schema validation across paginated tool discovery", async () => {
  309. const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
  310. server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
  311. Promise.resolve(
  312. params?.cursor === "page-2"
  313. ? {
  314. tools: [
  315. {
  316. name: "second",
  317. inputSchema: { type: "object" },
  318. outputSchema: {
  319. type: "object",
  320. properties: { value: { type: "number" } },
  321. required: ["value"],
  322. },
  323. },
  324. ],
  325. }
  326. : {
  327. tools: [
  328. {
  329. name: "first",
  330. inputSchema: { type: "object" },
  331. outputSchema: {
  332. type: "object",
  333. properties: { value: { type: "string" } },
  334. required: ["value"],
  335. },
  336. },
  337. ],
  338. nextCursor: "page-2",
  339. },
  340. ),
  341. )
  342. server.setRequestHandler(CallToolRequestSchema, ({ params }) =>
  343. Promise.resolve({
  344. content: [],
  345. structuredContent: { value: params.name === "first" ? 42 : 1 },
  346. }),
  347. )
  348. const client = new Client({ name: "pagination-test", version: "1.0.0" })
  349. const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
  350. await Promise.all([client.connect(clientTransport), server.connect(serverTransport)])
  351. try {
  352. const first = await client.listTools()
  353. const second = await client.listTools({ cursor: first.nextCursor })
  354. expect([...first.tools, ...second.tools].map((tool) => tool.name)).toEqual(["first", "second"])
  355. await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(
  356. "Structured content does not match the tool's output schema",
  357. )
  358. } finally {
  359. await Promise.all([client.close(), server.close()])
  360. }
  361. })
  362. test("retains output schemas across paginated MCP discovery", async () => {
  363. const tools = await Effect.runPromise(
  364. Effect.scoped(
  365. Effect.gen(function* () {
  366. const connection = yield* MCPClient.connect(
  367. "pagination",
  368. new ConfigMCP.Local({
  369. type: "local",
  370. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
  371. }),
  372. import.meta.dir,
  373. )
  374. return yield* connection.tools()
  375. }),
  376. ),
  377. )
  378. expect(tools.map((tool) => ({ name: tool.name, outputSchema: tool.outputSchema }))).toEqual([
  379. {
  380. name: "first",
  381. outputSchema: {
  382. type: "object",
  383. properties: { value: { type: "string" } },
  384. required: ["value"],
  385. },
  386. },
  387. {
  388. name: "second",
  389. outputSchema: {
  390. type: "object",
  391. properties: { value: { type: "number" } },
  392. required: ["value"],
  393. },
  394. },
  395. ])
  396. })
  397. test("applies the configured MCP catalog timeout", async () => {
  398. const result = Effect.runPromise(
  399. Effect.scoped(
  400. Effect.gen(function* () {
  401. const connection = yield* MCPClient.connect(
  402. "catalog-timeout",
  403. new ConfigMCP.Local({
  404. type: "local",
  405. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
  406. environment: { MCP_TIMEOUT_TARGET: "catalog" },
  407. timeout: new ConfigMCP.Timeout({ catalog: 10 }),
  408. }),
  409. import.meta.dir,
  410. )
  411. return yield* connection.tools()
  412. }),
  413. ),
  414. )
  415. await expect(result).rejects.toThrow("Request timed out")
  416. })
  417. test("applies the configured MCP execution timeout", async () => {
  418. const result = Effect.runPromise(
  419. Effect.scoped(
  420. Effect.gen(function* () {
  421. const connection = yield* MCPClient.connect(
  422. "execution-timeout",
  423. new ConfigMCP.Local({
  424. type: "local",
  425. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
  426. timeout: new ConfigMCP.Timeout({ execution: 10 }),
  427. }),
  428. import.meta.dir,
  429. )
  430. return yield* connection.callTool({ name: "slow" })
  431. }),
  432. ),
  433. )
  434. await expect(result).rejects.toThrow("Request timed out")
  435. })
  436. test("applies the configured MCP execution timeout to prompts", async () => {
  437. const result = Effect.runPromise(
  438. Effect.scoped(
  439. Effect.gen(function* () {
  440. const connection = yield* MCPClient.connect(
  441. "prompt-timeout",
  442. new ConfigMCP.Local({
  443. type: "local",
  444. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
  445. timeout: new ConfigMCP.Timeout({ execution: 10 }),
  446. }),
  447. import.meta.dir,
  448. )
  449. return yield* connection.prompt({ name: "slow" })
  450. }),
  451. ),
  452. )
  453. await expect(result).rejects.toThrow("Request timed out")
  454. })
  455. test("applies configured MCP timeouts to resource operations", async () => {
  456. const catalog = Effect.runPromise(
  457. Effect.scoped(
  458. Effect.gen(function* () {
  459. const connection = yield* MCPClient.connect(
  460. "resource-catalog-timeout",
  461. new ConfigMCP.Local({
  462. type: "local",
  463. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
  464. environment: { MCP_TIMEOUT_TARGET: "resource-catalog" },
  465. timeout: new ConfigMCP.Timeout({ catalog: 10 }),
  466. }),
  467. import.meta.dir,
  468. )
  469. return yield* connection.resources()
  470. }),
  471. ),
  472. )
  473. await expect(catalog).rejects.toThrow("Request timed out")
  474. const read = Effect.runPromise(
  475. Effect.scoped(
  476. Effect.gen(function* () {
  477. const connection = yield* MCPClient.connect(
  478. "resource-read-timeout",
  479. new ConfigMCP.Local({
  480. type: "local",
  481. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
  482. timeout: new ConfigMCP.Timeout({ execution: 10 }),
  483. }),
  484. import.meta.dir,
  485. )
  486. return yield* connection.readResource({ uri: "test://slow" })
  487. }),
  488. ),
  489. )
  490. await expect(read).rejects.toThrow("Request timed out")
  491. })
  492. test("lists, reads, and reports MCP resource changes", async () => {
  493. await Effect.runPromise(
  494. Effect.scoped(
  495. Effect.gen(function* () {
  496. const server = yield* resourceServer({ listChanged: true })
  497. server.state.resourcePages = {
  498. initial: {
  499. items: [{ name: "Readme", uri: "docs://readme", description: "Project docs" }],
  500. nextCursor: "resources-2",
  501. },
  502. "resources-2": { items: [{ name: "Logo", uri: "docs://logo", mimeType: "image/png" }] },
  503. }
  504. server.state.templatePages = {
  505. initial: {
  506. items: [{ name: "File", uriTemplate: "docs://{path}" }],
  507. nextCursor: "templates-2",
  508. },
  509. "templates-2": { items: [{ name: "Issue", uriTemplate: "issue://{id}", description: "Issue" }] },
  510. }
  511. const connection = yield* MCPClient.connect(
  512. "resources",
  513. new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
  514. import.meta.dir,
  515. )
  516. expect(yield* connection.resources()).toEqual([
  517. { name: "Readme", uri: "docs://readme", description: "Project docs", mimeType: undefined },
  518. { name: "Logo", uri: "docs://logo", description: undefined, mimeType: "image/png" },
  519. ])
  520. expect(yield* connection.resourceTemplates()).toEqual([
  521. { name: "File", uriTemplate: "docs://{path}", description: undefined, mimeType: undefined },
  522. { name: "Issue", uriTemplate: "issue://{id}", description: "Issue", mimeType: undefined },
  523. ])
  524. expect(yield* connection.readResource({ uri: "docs://readme" })).toEqual({
  525. contents: [
  526. { type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
  527. { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
  528. ],
  529. })
  530. const changed = yield* Deferred.make<void>()
  531. connection.onResourcesChanged(() => Deferred.doneUnsafe(changed, Exit.void))
  532. yield* Effect.promise(server.sendResourceListChanged)
  533. yield* Deferred.await(changed)
  534. }),
  535. ),
  536. )
  537. })
  538. test("does not reconnect an SSE stream after a JSON-RPC error response", async () => {
  539. let requests = 0
  540. const transport = new StreamableHTTPClientTransport(new URL("http://mcp.invalid"), {
  541. fetch: async () => {
  542. requests += 1
  543. return new Response(
  544. new ReadableStream({
  545. start(controller) {
  546. controller.enqueue(new TextEncoder().encode("id: prime\nretry: 1\ndata:\n\n"))
  547. controller.enqueue(
  548. new TextEncoder().encode(
  549. 'id: error\ndata: {"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}\n\n',
  550. ),
  551. )
  552. controller.close()
  553. },
  554. }),
  555. { status: 200, headers: { "content-type": "text/event-stream" } },
  556. )
  557. },
  558. reconnectionOptions: {
  559. initialReconnectionDelay: 1,
  560. maxReconnectionDelay: 1,
  561. reconnectionDelayGrowFactor: 1,
  562. maxRetries: 2,
  563. },
  564. })
  565. await transport.start()
  566. await transport.send({ jsonrpc: "2.0", method: "resources/list", id: 1 })
  567. await Bun.sleep(25)
  568. await transport.close()
  569. expect(requests).toBe(1)
  570. })
  571. test("skips MCP resource requests when the capability is absent", async () => {
  572. await Effect.runPromise(
  573. Effect.scoped(
  574. Effect.gen(function* () {
  575. const server = yield* resourceServer({ resources: false })
  576. const connection = yield* MCPClient.connect(
  577. "resources",
  578. new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
  579. import.meta.dir,
  580. )
  581. expect(yield* connection.resources()).toEqual([])
  582. expect(yield* connection.resourceTemplates()).toEqual([])
  583. expect(yield* connection.readResource({ uri: "docs://readme" })).toBeUndefined()
  584. expect({ resources: server.state.resourceLists, templates: server.state.templateLists }).toEqual({
  585. resources: 0,
  586. templates: 0,
  587. })
  588. }),
  589. ),
  590. )
  591. })
  592. test("accepts empty MCP elicitations without creating forms", async () => {
  593. await Effect.runPromise(
  594. Effect.scoped(
  595. Effect.gen(function* () {
  596. const server = yield* resourceServer({ resources: false, emptyElicitation: true })
  597. const result = yield* Effect.gen(function* () {
  598. const service = yield* MCP.Service
  599. const forms = yield* Form.Service
  600. const result = yield* service.callTool({ server: "resources", name: "empty-elicitation" })
  601. expect(yield* forms.list()).toEqual([])
  602. return result
  603. }).pipe(Effect.provide(resourceMcpLayer(server.url)))
  604. expect(result.structured).toEqual({ action: "accept", content: {} })
  605. }),
  606. ),
  607. )
  608. })
  609. test("acknowledges completed MCP URL elicitations without returning internal content", async () => {
  610. await Effect.runPromise(
  611. Effect.scoped(
  612. Effect.gen(function* () {
  613. const server = yield* resourceServer({ resources: false, urlElicitation: true })
  614. const created = yield* Deferred.make<Form.Info>()
  615. const result = yield* Effect.gen(function* () {
  616. const service = yield* MCP.Service
  617. const forms = yield* Form.Service
  618. const call = yield* service.callTool({ server: "resources", name: "url-elicitation" }).pipe(Effect.forkScoped)
  619. const form = yield* Deferred.await(created)
  620. expect(form.fields).toEqual([{ key: "elicitation", type: "external", url: "https://example.com/authorize" }])
  621. yield* Effect.promise(server.completeElicitation)
  622. const result = yield* Fiber.join(call)
  623. expect(yield* forms.state(form.id)).toEqual({ status: "answered", answer: { elicitation: true } })
  624. return result
  625. }).pipe(
  626. Effect.provide(resourceMcpLayer(server.url, (form) => Deferred.succeed(created, form).pipe(Effect.asVoid))),
  627. )
  628. expect(result.structured).toEqual({ action: "accept" })
  629. }),
  630. ),
  631. )
  632. })
  633. test("loads and reads MCP resources", async () => {
  634. await Effect.runPromise(
  635. Effect.scoped(
  636. Effect.gen(function* () {
  637. const server = yield* resourceServer()
  638. server.state.resources = [{ name: "Readme", uri: "docs://readme" }]
  639. server.state.templates = [{ name: "File", uriTemplate: "docs://{path}" }]
  640. yield* Effect.gen(function* () {
  641. const service = yield* MCP.Service
  642. expect(yield* service.resourceCatalog()).toEqual({
  643. resources: [
  644. {
  645. server: "resources",
  646. name: "Readme",
  647. uri: "docs://readme",
  648. description: undefined,
  649. mimeType: undefined,
  650. },
  651. ],
  652. templates: [
  653. {
  654. server: "resources",
  655. name: "File",
  656. uriTemplate: "docs://{path}",
  657. description: undefined,
  658. mimeType: undefined,
  659. },
  660. ],
  661. })
  662. server.state.resources = [{ name: "Guide", uri: "docs://guide" }]
  663. expect((yield* service.resourceCatalog()).resources.map((resource) => resource.uri)).toEqual(["docs://guide"])
  664. expect(yield* service.readResource({ server: "resources", uri: "docs://readme" })).toEqual({
  665. server: "resources",
  666. uri: "docs://readme",
  667. contents: [
  668. { type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
  669. { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
  670. ],
  671. })
  672. expect(server.clientVersion()).toMatchObject({ name: "sdk", version: "1.2.3" })
  673. }).pipe(
  674. Effect.provide(resourceMcpLayer(server.url, undefined, { clientInfo: { name: "sdk", version: "1.2.3" } })),
  675. )
  676. }),
  677. ),
  678. )
  679. })
  680. test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
  681. await Effect.runPromise(
  682. Effect.scoped(
  683. Effect.gen(function* () {
  684. yield* Effect.gen(function* () {
  685. const service = yield* MCP.Service
  686. expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
  687. expect(yield* service.connect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
  688. expect(yield* service.disconnect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
  689. yield* service.add(
  690. "dynamic",
  691. new ConfigMCP.Local({
  692. type: "local",
  693. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
  694. }),
  695. )
  696. expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
  697. status: "connected",
  698. })
  699. yield* service.add(
  700. "dynamic",
  701. new ConfigMCP.Local({
  702. type: "local",
  703. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
  704. disabled: true,
  705. }),
  706. )
  707. expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
  708. status: "disabled",
  709. })
  710. expect(yield* service.tools()).toEqual([])
  711. yield* service.connect("dynamic")
  712. expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
  713. status: "connected",
  714. })
  715. yield* service.disconnect("dynamic")
  716. expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
  717. status: "disabled",
  718. })
  719. expect(yield* service.tools()).toEqual([])
  720. yield* service.connect("dynamic")
  721. expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
  722. status: "connected",
  723. })
  724. yield* service.remove("dynamic")
  725. expect((yield* service.servers()).some((server) => server.name === "dynamic")).toBe(false)
  726. expect(yield* service.tools()).toEqual([])
  727. expect(yield* service.remove("dynamic").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
  728. }).pipe(
  729. Effect.provide(
  730. resourceMcpLayer(
  731. new ConfigMCP.Local({
  732. type: "local",
  733. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
  734. disabled: true,
  735. }),
  736. ),
  737. ),
  738. )
  739. }),
  740. ),
  741. )
  742. })
  743. test("serializes concurrent MCP lifecycle operations", async () => {
  744. await Effect.runPromise(
  745. Effect.scoped(
  746. Effect.gen(function* () {
  747. yield* Effect.gen(function* () {
  748. const service = yield* MCP.Service
  749. // Whatever order the racing operations land in, the resulting state must be consistent.
  750. yield* Effect.all(
  751. [
  752. service.connect("resources"),
  753. service.connect("resources"),
  754. service.disconnect("resources"),
  755. service.connect("resources"),
  756. ],
  757. { concurrency: "unbounded", discard: true },
  758. )
  759. const status = (yield* service.servers()).find((server) => server.name === "resources")?.status
  760. const tools = yield* service.tools()
  761. expect(status?.status === "connected" || status?.status === "disabled").toBe(true)
  762. if (status?.status === "disabled") expect(tools).toEqual([])
  763. if (status?.status === "connected") expect(tools.length).toBeGreaterThan(0)
  764. yield* service.disconnect("resources")
  765. expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
  766. expect(yield* service.tools()).toEqual([])
  767. yield* service.connect("resources")
  768. expect((yield* service.servers())[0]?.status).toEqual({ status: "connected" })
  769. expect((yield* service.tools()).length).toBeGreaterThan(0)
  770. }).pipe(
  771. Effect.provide(
  772. resourceMcpLayer(
  773. new ConfigMCP.Local({
  774. type: "local",
  775. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
  776. disabled: true,
  777. }),
  778. ),
  779. ),
  780. )
  781. }),
  782. ),
  783. )
  784. })
  785. it.effect("advertises MCP output schemas to Code Mode", () =>
  786. Effect.gen(function* () {
  787. const registry = yield* Tool.Service
  788. const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
  789. const execute = toolSet.definitions.find((tool) => tool.name === "execute")
  790. expect(toolSet.definitions.map((tool) => tool.name)).toEqual([
  791. "direct_fail",
  792. "direct_lookup",
  793. "direct_media",
  794. "execute",
  795. ])
  796. expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).toContain("ok: boolean")
  797. expect(execute?.description).not.toContain("tools.demo.search")
  798. }),
  799. )
  800. it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
  801. Effect.gen(function* () {
  802. const registry = yield* Tool.Service
  803. yield* waitForTool(registry, "direct_lookup")
  804. const definitions = yield* toolDefinitions(registry)
  805. const execute = definitions.find((tool) => tool.name === "execute")
  806. expect(definitions.some((tool) => tool.name === "direct_lookup")).toBe(true)
  807. expect(execute?.description).not.toContain("tools.direct.lookup")
  808. }),
  809. )
  810. // Baseline (PLAN.md step 1): MCP isError must become one failed tool call, not a
  811. // success whose text happens to describe an error.
  812. it.effect("fails the call when MCP reports isError", () =>
  813. Effect.gen(function* () {
  814. assertion = yield* Deferred.make<Permission.AssertInput>()
  815. decision = Effect.void
  816. const registry = yield* Tool.Service
  817. yield* waitForTool(registry, "direct_fail")
  818. const execution = yield* executeTool(registry, {
  819. sessionID: Session.ID.make("ses_mcp_is_error"),
  820. ...toolIdentity,
  821. call: { type: "tool-call", id: "call_mcp_is_error", name: "direct_fail", input: {} },
  822. })
  823. expect(execution).toMatchObject({ status: "error", error: { message: "search index unavailable" } })
  824. }),
  825. )
  826. // Baseline (PLAN.md step 1): mixed MCP text and media content must reach the model intact.
  827. it.effect("preserves MCP text and media content for the model", () =>
  828. Effect.gen(function* () {
  829. assertion = yield* Deferred.make<Permission.AssertInput>()
  830. decision = Effect.void
  831. const registry = yield* Tool.Service
  832. yield* waitForTool(registry, "direct_media")
  833. const execution = yield* executeTool(registry, {
  834. sessionID: Session.ID.make("ses_mcp_media"),
  835. ...toolIdentity,
  836. call: { type: "tool-call", id: "call_mcp_media", name: "direct_media", input: {} },
  837. })
  838. expect(execution.output).toBe("rendered chart")
  839. expect(execution.content).toMatchObject([
  840. { type: "text", text: "rendered chart" },
  841. { type: "file", mime: "image/png" },
  842. ])
  843. }),
  844. )
  845. it.effect("waits for permission before calling an MCP tool", () =>
  846. Effect.gen(function* () {
  847. calls = 0
  848. assertion = yield* Deferred.make<Permission.AssertInput>()
  849. const permission = yield* Deferred.make<void>()
  850. decision = Deferred.await(permission)
  851. const registry = yield* Tool.Service
  852. const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
  853. const fiber = yield* toolSet.execute({
  854. sessionID: Session.ID.make("ses_mcp_permission"),
  855. ...toolIdentity,
  856. call: {
  857. type: "tool-call",
  858. id: "call_mcp_permission",
  859. name: "execute",
  860. input: { code: "return await tools.demo.search({})" },
  861. },
  862. }).pipe(Effect.forkScoped)
  863. expect(yield* Deferred.await(assertion)).toEqual({
  864. action: "demo_search",
  865. resources: ["*"],
  866. save: ["*"],
  867. metadata: {},
  868. sessionID: Session.ID.make("ses_mcp_permission"),
  869. agent: toolIdentity.agent,
  870. source: {
  871. type: "tool",
  872. messageID: toolIdentity.messageID,
  873. id: "call_mcp_permission",
  874. },
  875. })
  876. expect(calls).toBe(0)
  877. yield* Deferred.succeed(permission, undefined)
  878. yield* Fiber.join(fiber)
  879. expect(calls).toBe(1)
  880. }),
  881. )
  882. it.effect("does not call MCP when permission is blocked", () =>
  883. Effect.gen(function* () {
  884. calls = 0
  885. assertion = yield* Deferred.make<Permission.AssertInput>()
  886. decision = Effect.fail(new Permission.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
  887. const registry = yield* Tool.Service
  888. const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
  889. const execution = yield* toolSet.execute({
  890. sessionID: Session.ID.make("ses_mcp_blocked"),
  891. ...toolIdentity,
  892. call: {
  893. type: "tool-call",
  894. id: "call_mcp_blocked",
  895. name: "execute",
  896. input: { code: "return await tools.demo.search({})" },
  897. },
  898. })
  899. expect(execution.content).toEqual([{ type: "text", text: "Unable to execute demo_search" }])
  900. expect(execution.metadata).toEqual({
  901. toolCalls: [{ tool: "demo.search", status: "error" }],
  902. error: true,
  903. })
  904. expect(calls).toBe(0)
  905. }),
  906. )