mcp.test.ts 34 KB

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