mcp.test.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703
  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/core/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 { testEffect } from "./lib/effect"
  33. import { location } from "./fixture/location"
  34. import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
  35. let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined
  36. let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void
  37. let calls = 0
  38. type ResourcePage = {
  39. items: Array<{ name: string; uri: string; description?: string; mimeType?: string }>
  40. nextCursor?: string
  41. }
  42. type ResourceTemplatePage = {
  43. items: Array<{ name: string; uriTemplate: string; description?: string; mimeType?: string }>
  44. nextCursor?: string
  45. }
  46. function resourceServer(
  47. input: { resources?: boolean; listChanged?: boolean; emptyElicitation?: boolean; urlElicitation?: boolean } = {},
  48. ) {
  49. return Effect.acquireRelease(
  50. Effect.promise(async () => {
  51. const state = {
  52. resources: [] as ResourcePage["items"],
  53. templates: [] as ResourceTemplatePage["items"],
  54. resourcePages: undefined as Record<string, ResourcePage> | undefined,
  55. templatePages: undefined as Record<string, ResourceTemplatePage> | undefined,
  56. contents: [
  57. { uri: "docs://readme", text: "hello", mimeType: "text/plain" },
  58. { uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
  59. ] as Array<{ uri: string; text: string; mimeType?: string } | { uri: string; blob: string; mimeType?: string }>,
  60. resourceLists: 0,
  61. templateLists: 0,
  62. }
  63. const protocol = new Server(
  64. { name: "mcp-resources", version: "1.0.0" },
  65. {
  66. capabilities: {
  67. tools: {},
  68. ...(input.resources === false ? {} : { resources: { listChanged: input.listChanged } }),
  69. },
  70. },
  71. )
  72. protocol.setRequestHandler(ListToolsRequestSchema, () =>
  73. Promise.resolve({
  74. tools: input.emptyElicitation
  75. ? [{ name: "empty-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
  76. : input.urlElicitation
  77. ? [{ name: "url-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
  78. : [],
  79. }),
  80. )
  81. if (input.emptyElicitation) {
  82. protocol.setRequestHandler(CallToolRequestSchema, async () => {
  83. const result = await protocol.elicitInput({
  84. mode: "form",
  85. message: "Confirm",
  86. requestedSchema: { type: "object", properties: {} },
  87. })
  88. return {
  89. content: [{ type: "text", text: JSON.stringify(result) }],
  90. structuredContent: result,
  91. }
  92. })
  93. }
  94. if (input.urlElicitation) {
  95. protocol.setRequestHandler(CallToolRequestSchema, async () => {
  96. const result = await protocol.elicitInput({
  97. mode: "url",
  98. message: "Authorize access",
  99. url: "https://example.com/authorize",
  100. elicitationId: "elicitation-test",
  101. })
  102. return {
  103. content: [{ type: "text", text: JSON.stringify(result) }],
  104. structuredContent: result,
  105. }
  106. })
  107. }
  108. if (input.resources !== false) {
  109. protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
  110. state.resourceLists += 1
  111. const page = state.resourcePages?.[request.params?.cursor ?? "initial"]
  112. return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor })
  113. })
  114. protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => {
  115. state.templateLists += 1
  116. const page = state.templatePages?.[request.params?.cursor ?? "initial"]
  117. return Promise.resolve({ resourceTemplates: page?.items ?? state.templates, nextCursor: page?.nextCursor })
  118. })
  119. protocol.setRequestHandler(ReadResourceRequestSchema, () => Promise.resolve({ contents: state.contents }))
  120. }
  121. const transport = new WebStandardStreamableHTTPServerTransport({
  122. sessionIdGenerator: () => crypto.randomUUID(),
  123. enableJsonResponse: true,
  124. })
  125. await protocol.connect(transport)
  126. const http = Bun.serve({
  127. port: 0,
  128. fetch: (request) => transport.handleRequest(request),
  129. })
  130. return {
  131. state,
  132. url: http.url.toString(),
  133. sendResourceListChanged: () => protocol.sendResourceListChanged(),
  134. completeElicitation: () => protocol.createElicitationCompletionNotifier("elicitation-test")(),
  135. close: async () => {
  136. await protocol.close().catch(() => {})
  137. await http.stop(true)
  138. },
  139. }
  140. }),
  141. (server) => Effect.promise(server.close),
  142. )
  143. }
  144. function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effect.Effect<void>) {
  145. const directory = AbsolutePath.make(import.meta.dir)
  146. const unusedIntegration = () => Effect.die("unused integration service")
  147. return MCP.layer.pipe(
  148. Layer.provideMerge(Form.layer),
  149. Layer.provide(
  150. Layer.mergeAll(
  151. Layer.succeed(
  152. Config.Service,
  153. Config.Service.of({
  154. entries: () =>
  155. Effect.succeed([
  156. new Config.Document({
  157. type: "document",
  158. info: new Config.Info({
  159. mcp: new ConfigMCP.Info({
  160. servers: { resources: new ConfigMCP.Remote({ type: "remote", url, oauth: false }) },
  161. }),
  162. }),
  163. }),
  164. ]),
  165. }),
  166. ),
  167. Layer.succeed(Location.Service, Location.Service.of(location({ directory }))),
  168. Layer.mock(EventV2.Service, {
  169. subscribe: () => Stream.never,
  170. publish: (definition, data) => {
  171. const event = {
  172. id: EventV2.ID.create(),
  173. type: definition.type,
  174. data,
  175. } as EventV2.Payload<typeof definition>
  176. if (event.type !== Form.Event.Created.type || !onFormCreated) return Effect.succeed(event)
  177. return onFormCreated(Schema.decodeUnknownSync(Form.Event.Created.data)(data).form).pipe(Effect.as(event))
  178. },
  179. }),
  180. Layer.mock(Integration.Service, {
  181. connection: {
  182. active: unusedIntegration,
  183. resolve: unusedIntegration,
  184. key: unusedIntegration,
  185. oauth: unusedIntegration,
  186. update: unusedIntegration,
  187. remove: unusedIntegration,
  188. },
  189. attempt: {
  190. status: unusedIntegration,
  191. complete: unusedIntegration,
  192. cancel: unusedIntegration,
  193. },
  194. }),
  195. Layer.mock(Credential.Service, {}),
  196. ),
  197. ),
  198. )
  199. }
  200. const mcp = Layer.mock(MCP.Service, {
  201. tools: () =>
  202. Effect.succeed([
  203. new MCP.Tool({
  204. server: MCP.ServerName.make("demo"),
  205. name: "search",
  206. description: "Search",
  207. inputSchema: { type: "object", properties: {} },
  208. outputSchema: {
  209. type: "object",
  210. properties: { ok: { type: "boolean" } },
  211. required: ["ok"],
  212. },
  213. }),
  214. ]),
  215. callTool: (input) =>
  216. Effect.sync(() => {
  217. calls += 1
  218. return new MCP.ToolResult({
  219. server: MCP.ServerName.make(input.server),
  220. tool: input.name,
  221. isError: false,
  222. structured: { ok: true },
  223. content: [],
  224. })
  225. }),
  226. })
  227. const permissions = Layer.mock(PermissionV2.Service, {
  228. assert: (input) =>
  229. Effect.gen(function* () {
  230. if (!assertion) return yield* Effect.die("Permission test is not initialized")
  231. yield* Deferred.succeed(assertion, input)
  232. yield* decision
  233. }),
  234. })
  235. const events = Layer.mock(EventV2.Service, { subscribe: () => Stream.never })
  236. const it = testEffect(
  237. AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, McpTool.node]), [
  238. [MCP.node, mcp],
  239. [PermissionV2.node, permissions],
  240. [EventV2.node, events],
  241. [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
  242. ]),
  243. )
  244. describe("MCP errors", () => {
  245. test("expose useful messages", () => {
  246. expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
  247. expect(
  248. new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message,
  249. ).toBe("failed")
  250. expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
  251. expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
  252. })
  253. })
  254. test("MCP tool names match V1 sanitization", () => {
  255. expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
  256. })
  257. test("preserves output schema validation across paginated tool discovery", async () => {
  258. const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
  259. server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
  260. Promise.resolve(
  261. params?.cursor === "page-2"
  262. ? {
  263. tools: [
  264. {
  265. name: "second",
  266. inputSchema: { type: "object" },
  267. outputSchema: {
  268. type: "object",
  269. properties: { value: { type: "number" } },
  270. required: ["value"],
  271. },
  272. },
  273. ],
  274. }
  275. : {
  276. tools: [
  277. {
  278. name: "first",
  279. inputSchema: { type: "object" },
  280. outputSchema: {
  281. type: "object",
  282. properties: { value: { type: "string" } },
  283. required: ["value"],
  284. },
  285. },
  286. ],
  287. nextCursor: "page-2",
  288. },
  289. ),
  290. )
  291. server.setRequestHandler(CallToolRequestSchema, ({ params }) =>
  292. Promise.resolve({
  293. content: [],
  294. structuredContent: { value: params.name === "first" ? 42 : 1 },
  295. }),
  296. )
  297. const client = new Client({ name: "pagination-test", version: "1.0.0" })
  298. const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
  299. await Promise.all([client.connect(clientTransport), server.connect(serverTransport)])
  300. try {
  301. const first = await client.listTools()
  302. const second = await client.listTools({ cursor: first.nextCursor })
  303. expect([...first.tools, ...second.tools].map((tool) => tool.name)).toEqual(["first", "second"])
  304. await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(
  305. "Structured content does not match the tool's output schema",
  306. )
  307. } finally {
  308. await Promise.all([client.close(), server.close()])
  309. }
  310. })
  311. test("retains output schemas across paginated MCP discovery", async () => {
  312. const tools = await Effect.runPromise(
  313. Effect.scoped(
  314. Effect.gen(function* () {
  315. const connection = yield* MCPClient.connect(
  316. "pagination",
  317. new ConfigMCP.Local({
  318. type: "local",
  319. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
  320. }),
  321. import.meta.dir,
  322. )
  323. return yield* connection.tools()
  324. }),
  325. ),
  326. )
  327. expect(tools.map((tool) => ({ name: tool.name, outputSchema: tool.outputSchema }))).toEqual([
  328. {
  329. name: "first",
  330. outputSchema: {
  331. type: "object",
  332. properties: { value: { type: "string" } },
  333. required: ["value"],
  334. },
  335. },
  336. {
  337. name: "second",
  338. outputSchema: {
  339. type: "object",
  340. properties: { value: { type: "number" } },
  341. required: ["value"],
  342. },
  343. },
  344. ])
  345. })
  346. test("applies the configured MCP catalog timeout", async () => {
  347. const result = Effect.runPromise(
  348. Effect.scoped(
  349. Effect.gen(function* () {
  350. const connection = yield* MCPClient.connect(
  351. "catalog-timeout",
  352. new ConfigMCP.Local({
  353. type: "local",
  354. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
  355. environment: { MCP_TIMEOUT_TARGET: "catalog" },
  356. timeout: new ConfigMCP.Timeout({ catalog: 10 }),
  357. }),
  358. import.meta.dir,
  359. )
  360. return yield* connection.tools()
  361. }),
  362. ),
  363. )
  364. await expect(result).rejects.toThrow("Request timed out")
  365. })
  366. test("applies the configured MCP execution timeout", async () => {
  367. const result = Effect.runPromise(
  368. Effect.scoped(
  369. Effect.gen(function* () {
  370. const connection = yield* MCPClient.connect(
  371. "execution-timeout",
  372. new ConfigMCP.Local({
  373. type: "local",
  374. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
  375. timeout: new ConfigMCP.Timeout({ execution: 10 }),
  376. }),
  377. import.meta.dir,
  378. )
  379. return yield* connection.callTool({ name: "slow" })
  380. }),
  381. ),
  382. )
  383. await expect(result).rejects.toThrow("Request timed out")
  384. })
  385. test("applies the configured MCP execution timeout to prompts", async () => {
  386. const result = Effect.runPromise(
  387. Effect.scoped(
  388. Effect.gen(function* () {
  389. const connection = yield* MCPClient.connect(
  390. "prompt-timeout",
  391. new ConfigMCP.Local({
  392. type: "local",
  393. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
  394. timeout: new ConfigMCP.Timeout({ execution: 10 }),
  395. }),
  396. import.meta.dir,
  397. )
  398. return yield* connection.prompt({ name: "slow" })
  399. }),
  400. ),
  401. )
  402. await expect(result).rejects.toThrow("Request timed out")
  403. })
  404. test("applies configured MCP timeouts to resource operations", async () => {
  405. const catalog = Effect.runPromise(
  406. Effect.scoped(
  407. Effect.gen(function* () {
  408. const connection = yield* MCPClient.connect(
  409. "resource-catalog-timeout",
  410. new ConfigMCP.Local({
  411. type: "local",
  412. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
  413. environment: { MCP_TIMEOUT_TARGET: "resource-catalog" },
  414. timeout: new ConfigMCP.Timeout({ catalog: 10 }),
  415. }),
  416. import.meta.dir,
  417. )
  418. return yield* connection.resources()
  419. }),
  420. ),
  421. )
  422. await expect(catalog).rejects.toThrow("Request timed out")
  423. const read = Effect.runPromise(
  424. Effect.scoped(
  425. Effect.gen(function* () {
  426. const connection = yield* MCPClient.connect(
  427. "resource-read-timeout",
  428. new ConfigMCP.Local({
  429. type: "local",
  430. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
  431. timeout: new ConfigMCP.Timeout({ execution: 10 }),
  432. }),
  433. import.meta.dir,
  434. )
  435. return yield* connection.readResource({ uri: "test://slow" })
  436. }),
  437. ),
  438. )
  439. await expect(read).rejects.toThrow("Request timed out")
  440. })
  441. test("lists, reads, and reports MCP resource changes", async () => {
  442. await Effect.runPromise(
  443. Effect.scoped(
  444. Effect.gen(function* () {
  445. const server = yield* resourceServer({ listChanged: true })
  446. server.state.resourcePages = {
  447. initial: {
  448. items: [{ name: "Readme", uri: "docs://readme", description: "Project docs" }],
  449. nextCursor: "resources-2",
  450. },
  451. "resources-2": { items: [{ name: "Logo", uri: "docs://logo", mimeType: "image/png" }] },
  452. }
  453. server.state.templatePages = {
  454. initial: {
  455. items: [{ name: "File", uriTemplate: "docs://{path}" }],
  456. nextCursor: "templates-2",
  457. },
  458. "templates-2": { items: [{ name: "Issue", uriTemplate: "issue://{id}", description: "Issue" }] },
  459. }
  460. const connection = yield* MCPClient.connect(
  461. "resources",
  462. new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
  463. import.meta.dir,
  464. )
  465. expect(yield* connection.resources()).toEqual([
  466. { name: "Readme", uri: "docs://readme", description: "Project docs", mimeType: undefined },
  467. { name: "Logo", uri: "docs://logo", description: undefined, mimeType: "image/png" },
  468. ])
  469. expect(yield* connection.resourceTemplates()).toEqual([
  470. { name: "File", uriTemplate: "docs://{path}", description: undefined, mimeType: undefined },
  471. { name: "Issue", uriTemplate: "issue://{id}", description: "Issue", mimeType: undefined },
  472. ])
  473. expect(yield* connection.readResource({ uri: "docs://readme" })).toEqual({
  474. contents: [
  475. { type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
  476. { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
  477. ],
  478. })
  479. const changed = yield* Deferred.make<void>()
  480. connection.onResourcesChanged(() => Deferred.doneUnsafe(changed, Exit.void))
  481. yield* Effect.promise(server.sendResourceListChanged)
  482. yield* Deferred.await(changed)
  483. }),
  484. ),
  485. )
  486. })
  487. test("skips MCP resource requests when the capability is absent", async () => {
  488. await Effect.runPromise(
  489. Effect.scoped(
  490. Effect.gen(function* () {
  491. const server = yield* resourceServer({ resources: false })
  492. const connection = yield* MCPClient.connect(
  493. "resources",
  494. new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
  495. import.meta.dir,
  496. )
  497. expect(yield* connection.resources()).toEqual([])
  498. expect(yield* connection.resourceTemplates()).toEqual([])
  499. expect(yield* connection.readResource({ uri: "docs://readme" })).toBeUndefined()
  500. expect({ resources: server.state.resourceLists, templates: server.state.templateLists }).toEqual({
  501. resources: 0,
  502. templates: 0,
  503. })
  504. }),
  505. ),
  506. )
  507. })
  508. test("accepts empty MCP elicitations without creating forms", async () => {
  509. await Effect.runPromise(
  510. Effect.scoped(
  511. Effect.gen(function* () {
  512. const server = yield* resourceServer({ resources: false, emptyElicitation: true })
  513. const result = yield* Effect.gen(function* () {
  514. const service = yield* MCP.Service
  515. const forms = yield* Form.Service
  516. const result = yield* service.callTool({ server: "resources", name: "empty-elicitation" })
  517. expect(yield* forms.list()).toEqual([])
  518. return result
  519. }).pipe(Effect.provide(resourceMcpLayer(server.url)))
  520. expect(result.structured).toEqual({ action: "accept", content: {} })
  521. }),
  522. ),
  523. )
  524. })
  525. test("acknowledges completed MCP URL elicitations without returning internal content", async () => {
  526. await Effect.runPromise(
  527. Effect.scoped(
  528. Effect.gen(function* () {
  529. const server = yield* resourceServer({ resources: false, urlElicitation: true })
  530. const created = yield* Deferred.make<Form.Info>()
  531. const result = yield* Effect.gen(function* () {
  532. const service = yield* MCP.Service
  533. const forms = yield* Form.Service
  534. const call = yield* service.callTool({ server: "resources", name: "url-elicitation" }).pipe(Effect.forkScoped)
  535. const form = yield* Deferred.await(created)
  536. expect(form.fields).toEqual([{ key: "elicitation", type: "external", url: "https://example.com/authorize" }])
  537. yield* Effect.promise(server.completeElicitation)
  538. const result = yield* Fiber.join(call)
  539. expect(yield* forms.state(form.id)).toEqual({ status: "answered", answer: { elicitation: true } })
  540. return result
  541. }).pipe(
  542. Effect.provide(resourceMcpLayer(server.url, (form) => Deferred.succeed(created, form).pipe(Effect.asVoid))),
  543. )
  544. expect(result.structured).toEqual({ action: "accept" })
  545. }),
  546. ),
  547. )
  548. })
  549. test("loads and reads MCP resources", async () => {
  550. await Effect.runPromise(
  551. Effect.scoped(
  552. Effect.gen(function* () {
  553. const server = yield* resourceServer()
  554. server.state.resources = [{ name: "Readme", uri: "docs://readme" }]
  555. server.state.templates = [{ name: "File", uriTemplate: "docs://{path}" }]
  556. yield* Effect.gen(function* () {
  557. const service = yield* MCP.Service
  558. expect(yield* service.resourceCatalog()).toEqual({
  559. resources: [
  560. {
  561. server: "resources",
  562. name: "Readme",
  563. uri: "docs://readme",
  564. description: undefined,
  565. mimeType: undefined,
  566. },
  567. ],
  568. templates: [
  569. {
  570. server: "resources",
  571. name: "File",
  572. uriTemplate: "docs://{path}",
  573. description: undefined,
  574. mimeType: undefined,
  575. },
  576. ],
  577. })
  578. server.state.resources = [{ name: "Guide", uri: "docs://guide" }]
  579. expect((yield* service.resourceCatalog()).resources.map((resource) => resource.uri)).toEqual(["docs://guide"])
  580. expect(yield* service.readResource({ server: "resources", uri: "docs://readme" })).toEqual({
  581. server: "resources",
  582. uri: "docs://readme",
  583. contents: [
  584. { type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
  585. { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
  586. ],
  587. })
  588. }).pipe(Effect.provide(resourceMcpLayer(server.url)))
  589. }),
  590. ),
  591. )
  592. })
  593. it.effect("advertises MCP output schemas to Code Mode", () =>
  594. Effect.gen(function* () {
  595. const registry = yield* ToolRegistry.Service
  596. yield* waitForTool(registry, "execute")
  597. const execute = (yield* toolDefinitions(registry)).find((tool) => tool.name === "execute")
  598. expect(execute?.description).toContain("tools.demo.search(input: {}): Promise<{\n ok: boolean,\n}>")
  599. }),
  600. )
  601. it.effect("waits for permission before calling an MCP tool", () =>
  602. Effect.gen(function* () {
  603. calls = 0
  604. assertion = yield* Deferred.make<PermissionV2.AssertInput>()
  605. const permission = yield* Deferred.make<void>()
  606. decision = Deferred.await(permission)
  607. const registry = yield* ToolRegistry.Service
  608. yield* waitForTool(registry, "execute")
  609. const fiber = yield* settleTool(registry, {
  610. sessionID: SessionV2.ID.make("ses_mcp_permission"),
  611. ...toolIdentity,
  612. call: {
  613. type: "tool-call",
  614. id: "call_mcp_permission",
  615. name: "execute",
  616. input: { code: "return await tools.demo.search({})" },
  617. },
  618. }).pipe(Effect.forkScoped)
  619. expect(yield* Deferred.await(assertion)).toEqual({
  620. action: "demo_search",
  621. resources: ["*"],
  622. save: ["*"],
  623. metadata: {},
  624. sessionID: SessionV2.ID.make("ses_mcp_permission"),
  625. agent: toolIdentity.agent,
  626. source: {
  627. type: "tool",
  628. messageID: toolIdentity.assistantMessageID,
  629. callID: "call_mcp_permission",
  630. },
  631. })
  632. expect(calls).toBe(0)
  633. yield* Deferred.succeed(permission, undefined)
  634. yield* Fiber.join(fiber)
  635. expect(calls).toBe(1)
  636. }),
  637. )
  638. it.effect("does not call MCP when permission is blocked", () =>
  639. Effect.gen(function* () {
  640. calls = 0
  641. assertion = yield* Deferred.make<PermissionV2.AssertInput>()
  642. decision = Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
  643. const registry = yield* ToolRegistry.Service
  644. yield* waitForTool(registry, "execute")
  645. const settlement = yield* settleTool(registry, {
  646. sessionID: SessionV2.ID.make("ses_mcp_blocked"),
  647. ...toolIdentity,
  648. call: {
  649. type: "tool-call",
  650. id: "call_mcp_blocked",
  651. name: "execute",
  652. input: { code: "return await tools.demo.search({})" },
  653. },
  654. })
  655. expect(settlement.result).toEqual({ type: "text", value: "Unable to execute demo_search" })
  656. expect(settlement.output?.structured).toEqual({
  657. toolCalls: [{ tool: "demo.search", status: "error" }],
  658. error: true,
  659. })
  660. expect(calls).toBe(0)
  661. }),
  662. )