mcp.test.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861
  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 { EventV2 } from "@opencode-ai/core/event"
  20. import { Form } from "@opencode-ai/core/form"
  21. import { Integration } from "@opencode-ai/core/integration"
  22. import { Location } from "@opencode-ai/core/location"
  23. import { MCP } from "@opencode-ai/core/mcp/index"
  24. import { MCPClient } from "@opencode-ai/core/mcp/client"
  25. import { PermissionV2 } from "@opencode-ai/core/permission"
  26. import { AbsolutePath } from "@opencode-ai/core/schema"
  27. import { SessionV2 } from "@opencode-ai/core/session"
  28. import { McpTool } from "@opencode-ai/core/tool/mcp"
  29. import { ToolRegistry } from "@opencode-ai/core/tool/registry"
  30. import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
  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 { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
  37. let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined
  38. let decision: Effect.Effect<void, PermissionV2.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. Layer.succeed(
  159. Config.Service,
  160. Config.Service.of({
  161. entries: () =>
  162. Effect.succeed([
  163. new Config.Document({
  164. type: "document",
  165. info: new Config.Info({
  166. mcp: new ConfigMCP.Info({
  167. servers: {
  168. resources:
  169. typeof server === "string"
  170. ? new ConfigMCP.Remote({ type: "remote", url: server, oauth: false })
  171. : server,
  172. },
  173. }),
  174. }),
  175. }),
  176. ]),
  177. }),
  178. ),
  179. Layer.succeed(Location.Service, Location.Service.of(location({ directory }))),
  180. Layer.mock(EventV2.Service, {
  181. subscribe: () => Stream.never,
  182. publish: (definition, data) => {
  183. const event = {
  184. id: EventV2.ID.create(),
  185. type: definition.type,
  186. data,
  187. } as EventV2.Payload<typeof definition>
  188. if (event.type !== Form.Event.Created.type || !onFormCreated) return Effect.succeed(event)
  189. return onFormCreated(Schema.decodeUnknownSync(Form.Event.Created.data)(data).form).pipe(Effect.as(event))
  190. },
  191. }),
  192. Layer.mock(Integration.Service, {
  193. connection: {
  194. active: unusedIntegration,
  195. resolve: unusedIntegration,
  196. key: unusedIntegration,
  197. update: unusedIntegration,
  198. remove: unusedIntegration,
  199. },
  200. oauth: {
  201. connect: unusedIntegration,
  202. status: unusedIntegration,
  203. complete: unusedIntegration,
  204. cancel: unusedIntegration,
  205. },
  206. command: {
  207. connect: unusedIntegration,
  208. status: unusedIntegration,
  209. cancel: unusedIntegration,
  210. },
  211. }),
  212. Layer.mock(Credential.Service, {}),
  213. ),
  214. ),
  215. )
  216. }
  217. const mcp = Layer.mock(MCP.Service, {
  218. tools: () =>
  219. Effect.succeed([
  220. new MCP.Tool({
  221. server: MCP.ServerName.make("demo"),
  222. name: "search",
  223. description: "Search",
  224. inputSchema: { type: "object", properties: {} },
  225. outputSchema: {
  226. type: "object",
  227. properties: { ok: { type: "boolean" } },
  228. required: ["ok"],
  229. },
  230. }),
  231. new MCP.Tool({
  232. server: MCP.ServerName.make("direct"),
  233. name: "lookup",
  234. codemode: false,
  235. description: "Lookup",
  236. inputSchema: { type: "object", properties: {} },
  237. }),
  238. ]),
  239. callTool: (input) =>
  240. Effect.sync(() => {
  241. calls += 1
  242. return new MCP.ToolResult({
  243. server: MCP.ServerName.make(input.server),
  244. tool: input.name,
  245. isError: false,
  246. structured: { ok: true },
  247. content: [],
  248. })
  249. }),
  250. })
  251. const permissions = Layer.mock(PermissionV2.Service, {
  252. assert: (input) =>
  253. Effect.gen(function* () {
  254. if (!assertion) return yield* Effect.die("Permission test is not initialized")
  255. yield* Deferred.succeed(assertion, input)
  256. yield* decision
  257. }),
  258. })
  259. const events = Layer.mock(EventV2.Service, { subscribe: () => Stream.never })
  260. const it = testEffect(
  261. AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, McpTool.node]), [
  262. [MCP.node, mcp],
  263. [PermissionV2.node, permissions],
  264. [EventV2.node, events],
  265. [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
  266. [Image.node, imagePassthrough],
  267. ]),
  268. )
  269. describe("MCP errors", () => {
  270. test("expose useful messages", () => {
  271. expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
  272. expect(
  273. new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message,
  274. ).toBe("failed")
  275. expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
  276. expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
  277. })
  278. })
  279. test("MCP tool names match V1 sanitization", () => {
  280. expect(McpTool.namespace("context 7")).toBe("context_7")
  281. expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
  282. })
  283. test("preserves output schema validation across paginated tool discovery", async () => {
  284. const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
  285. server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
  286. Promise.resolve(
  287. params?.cursor === "page-2"
  288. ? {
  289. tools: [
  290. {
  291. name: "second",
  292. inputSchema: { type: "object" },
  293. outputSchema: {
  294. type: "object",
  295. properties: { value: { type: "number" } },
  296. required: ["value"],
  297. },
  298. },
  299. ],
  300. }
  301. : {
  302. tools: [
  303. {
  304. name: "first",
  305. inputSchema: { type: "object" },
  306. outputSchema: {
  307. type: "object",
  308. properties: { value: { type: "string" } },
  309. required: ["value"],
  310. },
  311. },
  312. ],
  313. nextCursor: "page-2",
  314. },
  315. ),
  316. )
  317. server.setRequestHandler(CallToolRequestSchema, ({ params }) =>
  318. Promise.resolve({
  319. content: [],
  320. structuredContent: { value: params.name === "first" ? 42 : 1 },
  321. }),
  322. )
  323. const client = new Client({ name: "pagination-test", version: "1.0.0" })
  324. const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
  325. await Promise.all([client.connect(clientTransport), server.connect(serverTransport)])
  326. try {
  327. const first = await client.listTools()
  328. const second = await client.listTools({ cursor: first.nextCursor })
  329. expect([...first.tools, ...second.tools].map((tool) => tool.name)).toEqual(["first", "second"])
  330. await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(
  331. "Structured content does not match the tool's output schema",
  332. )
  333. } finally {
  334. await Promise.all([client.close(), server.close()])
  335. }
  336. })
  337. test("retains output schemas across paginated MCP discovery", async () => {
  338. const tools = await Effect.runPromise(
  339. Effect.scoped(
  340. Effect.gen(function* () {
  341. const connection = yield* MCPClient.connect(
  342. "pagination",
  343. new ConfigMCP.Local({
  344. type: "local",
  345. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
  346. }),
  347. import.meta.dir,
  348. )
  349. return yield* connection.tools()
  350. }),
  351. ),
  352. )
  353. expect(tools.map((tool) => ({ name: tool.name, outputSchema: tool.outputSchema }))).toEqual([
  354. {
  355. name: "first",
  356. outputSchema: {
  357. type: "object",
  358. properties: { value: { type: "string" } },
  359. required: ["value"],
  360. },
  361. },
  362. {
  363. name: "second",
  364. outputSchema: {
  365. type: "object",
  366. properties: { value: { type: "number" } },
  367. required: ["value"],
  368. },
  369. },
  370. ])
  371. })
  372. test("applies the configured MCP catalog timeout", async () => {
  373. const result = Effect.runPromise(
  374. Effect.scoped(
  375. Effect.gen(function* () {
  376. const connection = yield* MCPClient.connect(
  377. "catalog-timeout",
  378. new ConfigMCP.Local({
  379. type: "local",
  380. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
  381. environment: { MCP_TIMEOUT_TARGET: "catalog" },
  382. timeout: new ConfigMCP.Timeout({ catalog: 10 }),
  383. }),
  384. import.meta.dir,
  385. )
  386. return yield* connection.tools()
  387. }),
  388. ),
  389. )
  390. await expect(result).rejects.toThrow("Request timed out")
  391. })
  392. test("applies the configured MCP execution timeout", async () => {
  393. const result = Effect.runPromise(
  394. Effect.scoped(
  395. Effect.gen(function* () {
  396. const connection = yield* MCPClient.connect(
  397. "execution-timeout",
  398. new ConfigMCP.Local({
  399. type: "local",
  400. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
  401. timeout: new ConfigMCP.Timeout({ execution: 10 }),
  402. }),
  403. import.meta.dir,
  404. )
  405. return yield* connection.callTool({ name: "slow" })
  406. }),
  407. ),
  408. )
  409. await expect(result).rejects.toThrow("Request timed out")
  410. })
  411. test("applies the configured MCP execution timeout to prompts", async () => {
  412. const result = Effect.runPromise(
  413. Effect.scoped(
  414. Effect.gen(function* () {
  415. const connection = yield* MCPClient.connect(
  416. "prompt-timeout",
  417. new ConfigMCP.Local({
  418. type: "local",
  419. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
  420. timeout: new ConfigMCP.Timeout({ execution: 10 }),
  421. }),
  422. import.meta.dir,
  423. )
  424. return yield* connection.prompt({ name: "slow" })
  425. }),
  426. ),
  427. )
  428. await expect(result).rejects.toThrow("Request timed out")
  429. })
  430. test("applies configured MCP timeouts to resource operations", async () => {
  431. const catalog = Effect.runPromise(
  432. Effect.scoped(
  433. Effect.gen(function* () {
  434. const connection = yield* MCPClient.connect(
  435. "resource-catalog-timeout",
  436. new ConfigMCP.Local({
  437. type: "local",
  438. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
  439. environment: { MCP_TIMEOUT_TARGET: "resource-catalog" },
  440. timeout: new ConfigMCP.Timeout({ catalog: 10 }),
  441. }),
  442. import.meta.dir,
  443. )
  444. return yield* connection.resources()
  445. }),
  446. ),
  447. )
  448. await expect(catalog).rejects.toThrow("Request timed out")
  449. const read = Effect.runPromise(
  450. Effect.scoped(
  451. Effect.gen(function* () {
  452. const connection = yield* MCPClient.connect(
  453. "resource-read-timeout",
  454. new ConfigMCP.Local({
  455. type: "local",
  456. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
  457. timeout: new ConfigMCP.Timeout({ execution: 10 }),
  458. }),
  459. import.meta.dir,
  460. )
  461. return yield* connection.readResource({ uri: "test://slow" })
  462. }),
  463. ),
  464. )
  465. await expect(read).rejects.toThrow("Request timed out")
  466. })
  467. test("lists, reads, and reports MCP resource changes", async () => {
  468. await Effect.runPromise(
  469. Effect.scoped(
  470. Effect.gen(function* () {
  471. const server = yield* resourceServer({ listChanged: true })
  472. server.state.resourcePages = {
  473. initial: {
  474. items: [{ name: "Readme", uri: "docs://readme", description: "Project docs" }],
  475. nextCursor: "resources-2",
  476. },
  477. "resources-2": { items: [{ name: "Logo", uri: "docs://logo", mimeType: "image/png" }] },
  478. }
  479. server.state.templatePages = {
  480. initial: {
  481. items: [{ name: "File", uriTemplate: "docs://{path}" }],
  482. nextCursor: "templates-2",
  483. },
  484. "templates-2": { items: [{ name: "Issue", uriTemplate: "issue://{id}", description: "Issue" }] },
  485. }
  486. const connection = yield* MCPClient.connect(
  487. "resources",
  488. new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
  489. import.meta.dir,
  490. )
  491. expect(yield* connection.resources()).toEqual([
  492. { name: "Readme", uri: "docs://readme", description: "Project docs", mimeType: undefined },
  493. { name: "Logo", uri: "docs://logo", description: undefined, mimeType: "image/png" },
  494. ])
  495. expect(yield* connection.resourceTemplates()).toEqual([
  496. { name: "File", uriTemplate: "docs://{path}", description: undefined, mimeType: undefined },
  497. { name: "Issue", uriTemplate: "issue://{id}", description: "Issue", mimeType: undefined },
  498. ])
  499. expect(yield* connection.readResource({ uri: "docs://readme" })).toEqual({
  500. contents: [
  501. { type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
  502. { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
  503. ],
  504. })
  505. const changed = yield* Deferred.make<void>()
  506. connection.onResourcesChanged(() => Deferred.doneUnsafe(changed, Exit.void))
  507. yield* Effect.promise(server.sendResourceListChanged)
  508. yield* Deferred.await(changed)
  509. }),
  510. ),
  511. )
  512. })
  513. test("skips MCP resource requests when the capability is absent", async () => {
  514. await Effect.runPromise(
  515. Effect.scoped(
  516. Effect.gen(function* () {
  517. const server = yield* resourceServer({ resources: false })
  518. const connection = yield* MCPClient.connect(
  519. "resources",
  520. new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
  521. import.meta.dir,
  522. )
  523. expect(yield* connection.resources()).toEqual([])
  524. expect(yield* connection.resourceTemplates()).toEqual([])
  525. expect(yield* connection.readResource({ uri: "docs://readme" })).toBeUndefined()
  526. expect({ resources: server.state.resourceLists, templates: server.state.templateLists }).toEqual({
  527. resources: 0,
  528. templates: 0,
  529. })
  530. }),
  531. ),
  532. )
  533. })
  534. test("accepts empty MCP elicitations without creating forms", async () => {
  535. await Effect.runPromise(
  536. Effect.scoped(
  537. Effect.gen(function* () {
  538. const server = yield* resourceServer({ resources: false, emptyElicitation: true })
  539. const result = yield* Effect.gen(function* () {
  540. const service = yield* MCP.Service
  541. const forms = yield* Form.Service
  542. const result = yield* service.callTool({ server: "resources", name: "empty-elicitation" })
  543. expect(yield* forms.list()).toEqual([])
  544. return result
  545. }).pipe(Effect.provide(resourceMcpLayer(server.url)))
  546. expect(result.structured).toEqual({ action: "accept", content: {} })
  547. }),
  548. ),
  549. )
  550. })
  551. test("acknowledges completed MCP URL elicitations without returning internal content", async () => {
  552. await Effect.runPromise(
  553. Effect.scoped(
  554. Effect.gen(function* () {
  555. const server = yield* resourceServer({ resources: false, urlElicitation: true })
  556. const created = yield* Deferred.make<Form.Info>()
  557. const result = yield* Effect.gen(function* () {
  558. const service = yield* MCP.Service
  559. const forms = yield* Form.Service
  560. const call = yield* service.callTool({ server: "resources", name: "url-elicitation" }).pipe(Effect.forkScoped)
  561. const form = yield* Deferred.await(created)
  562. expect(form.fields).toEqual([{ key: "elicitation", type: "external", url: "https://example.com/authorize" }])
  563. yield* Effect.promise(server.completeElicitation)
  564. const result = yield* Fiber.join(call)
  565. expect(yield* forms.state(form.id)).toEqual({ status: "answered", answer: { elicitation: true } })
  566. return result
  567. }).pipe(
  568. Effect.provide(resourceMcpLayer(server.url, (form) => Deferred.succeed(created, form).pipe(Effect.asVoid))),
  569. )
  570. expect(result.structured).toEqual({ action: "accept" })
  571. }),
  572. ),
  573. )
  574. })
  575. test("loads and reads MCP resources", async () => {
  576. await Effect.runPromise(
  577. Effect.scoped(
  578. Effect.gen(function* () {
  579. const server = yield* resourceServer()
  580. server.state.resources = [{ name: "Readme", uri: "docs://readme" }]
  581. server.state.templates = [{ name: "File", uriTemplate: "docs://{path}" }]
  582. yield* Effect.gen(function* () {
  583. const service = yield* MCP.Service
  584. expect(yield* service.resourceCatalog()).toEqual({
  585. resources: [
  586. {
  587. server: "resources",
  588. name: "Readme",
  589. uri: "docs://readme",
  590. description: undefined,
  591. mimeType: undefined,
  592. },
  593. ],
  594. templates: [
  595. {
  596. server: "resources",
  597. name: "File",
  598. uriTemplate: "docs://{path}",
  599. description: undefined,
  600. mimeType: undefined,
  601. },
  602. ],
  603. })
  604. server.state.resources = [{ name: "Guide", uri: "docs://guide" }]
  605. expect((yield* service.resourceCatalog()).resources.map((resource) => resource.uri)).toEqual(["docs://guide"])
  606. expect(yield* service.readResource({ server: "resources", uri: "docs://readme" })).toEqual({
  607. server: "resources",
  608. uri: "docs://readme",
  609. contents: [
  610. { type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
  611. { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
  612. ],
  613. })
  614. expect(server.clientVersion()).toMatchObject({ name: "sdk", version: "1.2.3" })
  615. }).pipe(
  616. Effect.provide(
  617. resourceMcpLayer(server.url, undefined, { clientInfo: { name: "sdk", version: "1.2.3" } }),
  618. ),
  619. )
  620. }),
  621. ),
  622. )
  623. })
  624. test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
  625. await Effect.runPromise(
  626. Effect.scoped(
  627. Effect.gen(function* () {
  628. yield* Effect.gen(function* () {
  629. const service = yield* MCP.Service
  630. expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
  631. expect(yield* service.connect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
  632. expect(yield* service.disconnect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
  633. yield* service.add(
  634. "dynamic",
  635. new ConfigMCP.Local({
  636. type: "local",
  637. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
  638. }),
  639. )
  640. expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
  641. status: "connected",
  642. })
  643. yield* service.add(
  644. "dynamic",
  645. new ConfigMCP.Local({
  646. type: "local",
  647. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
  648. disabled: true,
  649. }),
  650. )
  651. expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
  652. status: "disabled",
  653. })
  654. expect(yield* service.tools()).toEqual([])
  655. yield* service.connect("dynamic")
  656. expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
  657. status: "connected",
  658. })
  659. yield* service.disconnect("dynamic")
  660. expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
  661. status: "disabled",
  662. })
  663. expect(yield* service.tools()).toEqual([])
  664. yield* service.connect("dynamic")
  665. expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
  666. status: "connected",
  667. })
  668. yield* service.remove("dynamic")
  669. expect((yield* service.servers()).some((server) => server.name === "dynamic")).toBe(false)
  670. expect(yield* service.tools()).toEqual([])
  671. expect(yield* service.remove("dynamic").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
  672. }).pipe(
  673. Effect.provide(
  674. resourceMcpLayer(
  675. new ConfigMCP.Local({
  676. type: "local",
  677. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
  678. disabled: true,
  679. }),
  680. ),
  681. ),
  682. )
  683. }),
  684. ),
  685. )
  686. })
  687. test("serializes concurrent MCP lifecycle operations", async () => {
  688. await Effect.runPromise(
  689. Effect.scoped(
  690. Effect.gen(function* () {
  691. yield* Effect.gen(function* () {
  692. const service = yield* MCP.Service
  693. // Whatever order the racing operations land in, the resulting state must be consistent.
  694. yield* Effect.all(
  695. [
  696. service.connect("resources"),
  697. service.connect("resources"),
  698. service.disconnect("resources"),
  699. service.connect("resources"),
  700. ],
  701. { concurrency: "unbounded", discard: true },
  702. )
  703. const status = (yield* service.servers()).find((server) => server.name === "resources")?.status
  704. const tools = yield* service.tools()
  705. expect(status?.status === "connected" || status?.status === "disabled").toBe(true)
  706. if (status?.status === "disabled") expect(tools).toEqual([])
  707. if (status?.status === "connected") expect(tools.length).toBeGreaterThan(0)
  708. yield* service.disconnect("resources")
  709. expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
  710. expect(yield* service.tools()).toEqual([])
  711. yield* service.connect("resources")
  712. expect((yield* service.servers())[0]?.status).toEqual({ status: "connected" })
  713. expect((yield* service.tools()).length).toBeGreaterThan(0)
  714. }).pipe(
  715. Effect.provide(
  716. resourceMcpLayer(
  717. new ConfigMCP.Local({
  718. type: "local",
  719. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
  720. disabled: true,
  721. }),
  722. ),
  723. ),
  724. )
  725. }),
  726. ),
  727. )
  728. })
  729. it.effect("advertises MCP output schemas to Code Mode", () =>
  730. Effect.gen(function* () {
  731. const registry = yield* ToolRegistry.Service
  732. yield* waitForTool(registry, "execute")
  733. const materialized = yield* registry.materialize()
  734. const execute = materialized.definitions.find((tool) => tool.name === "execute")
  735. expect(execute?.description).not.toContain("tools.demo.search")
  736. }),
  737. )
  738. it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
  739. Effect.gen(function* () {
  740. const registry = yield* ToolRegistry.Service
  741. yield* waitForTool(registry, "direct_lookup")
  742. const definitions = yield* toolDefinitions(registry)
  743. const execute = definitions.find((tool) => tool.name === "execute")
  744. expect(definitions.some((tool) => tool.name === "direct_lookup")).toBe(true)
  745. expect(execute?.description).not.toContain("tools.direct.lookup")
  746. }),
  747. )
  748. it.effect("waits for permission before calling an MCP tool", () =>
  749. Effect.gen(function* () {
  750. calls = 0
  751. assertion = yield* Deferred.make<PermissionV2.AssertInput>()
  752. const permission = yield* Deferred.make<void>()
  753. decision = Deferred.await(permission)
  754. const registry = yield* ToolRegistry.Service
  755. yield* waitForTool(registry, "execute")
  756. const fiber = yield* settleTool(registry, {
  757. sessionID: SessionV2.ID.make("ses_mcp_permission"),
  758. ...toolIdentity,
  759. call: {
  760. type: "tool-call",
  761. id: "call_mcp_permission",
  762. name: "execute",
  763. input: { code: "return await tools.demo.search({})" },
  764. },
  765. }).pipe(Effect.forkScoped)
  766. expect(yield* Deferred.await(assertion)).toEqual({
  767. action: "demo_search",
  768. resources: ["*"],
  769. save: ["*"],
  770. metadata: {},
  771. sessionID: SessionV2.ID.make("ses_mcp_permission"),
  772. agent: toolIdentity.agent,
  773. source: {
  774. type: "tool",
  775. messageID: toolIdentity.messageID,
  776. callID: "call_mcp_permission",
  777. },
  778. })
  779. expect(calls).toBe(0)
  780. yield* Deferred.succeed(permission, undefined)
  781. yield* Fiber.join(fiber)
  782. expect(calls).toBe(1)
  783. }),
  784. )
  785. it.effect("does not call MCP when permission is blocked", () =>
  786. Effect.gen(function* () {
  787. calls = 0
  788. assertion = yield* Deferred.make<PermissionV2.AssertInput>()
  789. decision = Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
  790. const registry = yield* ToolRegistry.Service
  791. yield* waitForTool(registry, "execute")
  792. const settlement = yield* settleTool(registry, {
  793. sessionID: SessionV2.ID.make("ses_mcp_blocked"),
  794. ...toolIdentity,
  795. call: {
  796. type: "tool-call",
  797. id: "call_mcp_blocked",
  798. name: "execute",
  799. input: { code: "return await tools.demo.search({})" },
  800. },
  801. })
  802. expect(settlement.result).toEqual({ type: "text", value: "Unable to execute demo_search" })
  803. expect(settlement.output?.structured).toEqual({
  804. toolCalls: [{ tool: "demo.search", status: "error" }],
  805. error: true,
  806. })
  807. expect(calls).toBe(0)
  808. }),
  809. )