mcp.test.ts 35 KB

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