mcp.test.ts 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309
  1. import path from "node:path"
  2. import { describe, expect, test } from "bun:test"
  3. import { Client } from "@modelcontextprotocol/sdk/client/index.js"
  4. import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
  5. import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
  6. import { Server } from "@modelcontextprotocol/sdk/server/index.js"
  7. import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
  8. import {
  9. CallToolRequestSchema,
  10. ListResourcesRequestSchema,
  11. ListResourceTemplatesRequestSchema,
  12. ListToolsRequestSchema,
  13. ReadResourceRequestSchema,
  14. } from "@modelcontextprotocol/sdk/types.js"
  15. import { Document, Event, Info } from "@opencode-ai/schema/config"
  16. import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
  17. import { Config } from "@opencode-ai/core/config"
  18. import { Credential } from "@opencode-ai/core/credential"
  19. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  20. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  21. import { Bus } from "@opencode-ai/core/bus"
  22. import { ID, type Payload } from "@opencode-ai/schema/event"
  23. import { Form } from "@opencode-ai/core/form"
  24. import { Integration } from "@opencode-ai/core/integration"
  25. import { Environment } from "@opencode-ai/core/environment/index"
  26. import { EnvironmentUnavailable } from "@opencode-ai/core/environment/unavailable"
  27. import { Location } from "@opencode-ai/core/location"
  28. import { MCP } from "@opencode-ai/core/mcp/index"
  29. import { MCPClient } from "@opencode-ai/core/mcp/client"
  30. import { MCPStdio } from "@opencode-ai/core/mcp/stdio"
  31. import { Permission } from "@opencode-ai/core/permission"
  32. import { AbsolutePath } from "@opencode-ai/core/schema"
  33. import { Session } from "@opencode-ai/core/session"
  34. import { McpTool } from "@opencode-ai/core/tool/mcp"
  35. import { Tool } from "@opencode-ai/core/tool"
  36. import { DateTime, Deferred, Effect, Exit, Fiber, Layer, PubSub, Schedule, Schema, Sink, Stream } from "effect"
  37. import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
  38. import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
  39. import { Image } from "@opencode-ai/core/image"
  40. import { testEffect } from "./lib/effect"
  41. import { imagePassthrough } from "./lib/image"
  42. import { location } from "./fixture/location"
  43. import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
  44. import { executeTool, toolDefinitions, toolIdentity, waitForCodeModeTool, waitForTool } from "./lib/tool"
  45. let assertion: Deferred.Deferred<Permission.AssertInput> | undefined
  46. let decision: Effect.Effect<void, Permission.Error> = Effect.void
  47. let calls = 0
  48. type ResourcePage = {
  49. items: Array<{ name: string; uri: string; description?: string; mimeType?: string }>
  50. nextCursor?: string
  51. }
  52. type ResourceTemplatePage = {
  53. items: Array<{ name: string; uriTemplate: string; description?: string; mimeType?: string }>
  54. nextCursor?: string
  55. }
  56. function resourceServer(
  57. input: { resources?: boolean; listChanged?: boolean; emptyElicitation?: boolean; urlElicitation?: boolean } = {},
  58. ) {
  59. return Effect.acquireRelease(
  60. Effect.promise(async () => {
  61. const state = {
  62. resources: [] as ResourcePage["items"],
  63. templates: [] as ResourceTemplatePage["items"],
  64. resourcePages: undefined as Record<string, ResourcePage> | undefined,
  65. templatePages: undefined as Record<string, ResourceTemplatePage> | undefined,
  66. contents: [
  67. { uri: "docs://readme", text: "hello", mimeType: "text/plain" },
  68. { uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
  69. ] as Array<{ uri: string; text: string; mimeType?: string } | { uri: string; blob: string; mimeType?: string }>,
  70. resourceLists: 0,
  71. templateLists: 0,
  72. toolLists: 0,
  73. initializations: 0,
  74. }
  75. const protocol = new Server(
  76. { name: "mcp-resources", version: "1.0.0" },
  77. {
  78. capabilities: {
  79. tools: {},
  80. ...(input.resources === false ? {} : { resources: { listChanged: input.listChanged } }),
  81. },
  82. },
  83. )
  84. protocol.setRequestHandler(ListToolsRequestSchema, () => {
  85. state.toolLists += 1
  86. return Promise.resolve({
  87. tools: input.emptyElicitation
  88. ? [{ name: "empty-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
  89. : input.urlElicitation
  90. ? [{ name: "url-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
  91. : [],
  92. })
  93. })
  94. if (input.emptyElicitation) {
  95. protocol.setRequestHandler(CallToolRequestSchema, async () => {
  96. const result = await protocol.elicitInput({
  97. mode: "form",
  98. message: "Confirm",
  99. requestedSchema: { type: "object", properties: {} },
  100. })
  101. return {
  102. content: [{ type: "text", text: JSON.stringify(result) }],
  103. structuredContent: result,
  104. }
  105. })
  106. }
  107. if (input.urlElicitation) {
  108. protocol.setRequestHandler(CallToolRequestSchema, async () => {
  109. const result = await protocol.elicitInput({
  110. mode: "url",
  111. message: "Authorize access",
  112. url: "https://example.com/authorize",
  113. elicitationId: "elicitation-test",
  114. })
  115. return {
  116. content: [{ type: "text", text: JSON.stringify(result) }],
  117. structuredContent: result,
  118. }
  119. })
  120. }
  121. if (input.resources !== false) {
  122. protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
  123. state.resourceLists += 1
  124. const page = state.resourcePages?.[request.params?.cursor ?? "initial"]
  125. return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor })
  126. })
  127. protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => {
  128. state.templateLists += 1
  129. const page = state.templatePages?.[request.params?.cursor ?? "initial"]
  130. return Promise.resolve({ resourceTemplates: page?.items ?? state.templates, nextCursor: page?.nextCursor })
  131. })
  132. protocol.setRequestHandler(ReadResourceRequestSchema, () => Promise.resolve({ contents: state.contents }))
  133. }
  134. const transport = new WebStandardStreamableHTTPServerTransport({
  135. sessionIdGenerator: () => crypto.randomUUID(),
  136. enableJsonResponse: true,
  137. })
  138. await protocol.connect(transport)
  139. const http = Bun.serve({
  140. port: 0,
  141. fetch: async (request) => {
  142. const body: unknown = request.method === "POST" ? await request.clone().json() : undefined
  143. if (typeof body === "object" && body !== null && "method" in body && body.method === "initialize") {
  144. state.initializations += 1
  145. }
  146. return transport.handleRequest(request)
  147. },
  148. })
  149. return {
  150. state,
  151. url: http.url.toString(),
  152. clientVersion: () => protocol.getClientVersion(),
  153. sendResourceListChanged: () => protocol.sendResourceListChanged(),
  154. completeElicitation: () => protocol.createElicitationCompletionNotifier("elicitation-test")(),
  155. close: async () => {
  156. await protocol.close().catch(() => {})
  157. await http.stop(true)
  158. },
  159. }
  160. }),
  161. (server) => Effect.promise(server.close),
  162. )
  163. }
  164. function resourceMcpLayer(
  165. server: string | typeof ConfigMCP.Server.Type,
  166. onFormCreated?: (form: Form.Info) => Effect.Effect<void>,
  167. options?: MCP.Options,
  168. overrides?: {
  169. entries?: Config.Interface["entries"]
  170. subscribe?: Bus.Interface["subscribe"]
  171. environment?: Layer.Layer<Environment.Service>
  172. },
  173. ) {
  174. const directory = AbsolutePath.make(import.meta.dir)
  175. const unusedIntegration = () => Effect.die("unused integration service")
  176. return MCP.layer(options).pipe(
  177. Layer.provideMerge(Form.layer),
  178. Layer.provide(
  179. Layer.mergeAll(
  180. overrides?.entries
  181. ? Layer.succeed(
  182. Config.Service,
  183. Config.Service.of({
  184. entries: overrides.entries,
  185. update: () => Effect.die("unused config update"),
  186. changes: () => Stream.never,
  187. }),
  188. )
  189. : Config.testLayer([
  190. new Document({
  191. type: "document",
  192. info: new Info({
  193. mcp: new ConfigMCP.Info({
  194. servers: {
  195. resources:
  196. typeof server === "string"
  197. ? new ConfigMCP.Remote({ type: "remote", url: server, oauth: false })
  198. : server,
  199. },
  200. }),
  201. }),
  202. }),
  203. ]),
  204. Layer.succeed(Location.Service, Location.Service.of(location({ directory }))),
  205. Layer.mock(Bus.Service, {
  206. subscribe: overrides?.subscribe ?? (() => Stream.never),
  207. publish: (definition, data) => {
  208. const event = {
  209. id: ID.create(),
  210. type: definition.type,
  211. data,
  212. } as Payload<typeof definition>
  213. if (event.type !== Form.Event.Created.type || !onFormCreated) return Effect.succeed(event)
  214. return onFormCreated(Schema.decodeUnknownSync(Form.Event.Created.data)(data).form).pipe(Effect.as(event))
  215. },
  216. }),
  217. Layer.mock(Integration.Service, {
  218. connection: {
  219. active: unusedIntegration,
  220. resolve: unusedIntegration,
  221. key: unusedIntegration,
  222. update: unusedIntegration,
  223. remove: unusedIntegration,
  224. },
  225. oauth: {
  226. connect: unusedIntegration,
  227. status: unusedIntegration,
  228. complete: unusedIntegration,
  229. cancel: unusedIntegration,
  230. },
  231. command: {
  232. connect: unusedIntegration,
  233. status: unusedIntegration,
  234. cancel: unusedIntegration,
  235. },
  236. }),
  237. Layer.mock(Credential.Service, {}),
  238. overrides?.environment ?? hostEnvironmentLayer,
  239. ),
  240. ),
  241. )
  242. }
  243. const connect = (server: string, config: typeof ConfigMCP.Server.Type, directory: string) =>
  244. MCPClient.connect(server, config, directory).pipe(Effect.provide(hostEnvironmentLayer))
  245. const mcp = Layer.mock(MCP.Service, {
  246. tools: () =>
  247. Effect.succeed([
  248. new MCP.Tool({
  249. server: MCP.ServerName.make("demo"),
  250. name: "search",
  251. description: "Search",
  252. inputSchema: { type: "object", properties: {} },
  253. outputSchema: {
  254. type: "object",
  255. properties: { ok: { type: "boolean" } },
  256. required: ["ok"],
  257. },
  258. }),
  259. new MCP.Tool({
  260. server: MCP.ServerName.make("demo"),
  261. name: "status",
  262. description: "Status",
  263. inputSchema: { type: "object", properties: {} },
  264. }),
  265. new MCP.Tool({
  266. server: MCP.ServerName.make("direct"),
  267. name: "lookup",
  268. codemode: false,
  269. description: "Lookup",
  270. inputSchema: { type: "object", properties: {} },
  271. }),
  272. new MCP.Tool({
  273. server: MCP.ServerName.make("direct"),
  274. name: "fail",
  275. codemode: false,
  276. description: "Always fails",
  277. inputSchema: { type: "object", properties: {} },
  278. }),
  279. new MCP.Tool({
  280. server: MCP.ServerName.make("direct"),
  281. name: "media",
  282. codemode: false,
  283. description: "Returns text and an image",
  284. inputSchema: { type: "object", properties: {} },
  285. }),
  286. ]),
  287. callTool: (input) =>
  288. Effect.sync(() => {
  289. calls += 1
  290. if (input.name === "fail")
  291. return new MCP.ToolResult({
  292. server: MCP.ServerName.make(input.server),
  293. tool: input.name,
  294. isError: true,
  295. content: [{ type: "text", text: "search index unavailable" }],
  296. })
  297. if (input.name === "media")
  298. return new MCP.ToolResult({
  299. server: MCP.ServerName.make(input.server),
  300. tool: input.name,
  301. isError: false,
  302. content: [
  303. { type: "text", text: "rendered chart" },
  304. { type: "media", data: "aGVsbG8=", mimeType: "image/png" },
  305. ],
  306. })
  307. if (input.name === "status")
  308. return new MCP.ToolResult({
  309. server: MCP.ServerName.make(input.server),
  310. tool: input.name,
  311. isError: false,
  312. content: [{ type: "text", text: "hello" }],
  313. })
  314. return new MCP.ToolResult({
  315. server: MCP.ServerName.make(input.server),
  316. tool: input.name,
  317. isError: false,
  318. structured: { ok: true },
  319. content: [],
  320. })
  321. }),
  322. })
  323. const permissions = Layer.mock(Permission.Service, {
  324. assert: (input) =>
  325. Effect.gen(function* () {
  326. if (!assertion) return yield* Effect.die("Permission test is not initialized")
  327. yield* Deferred.succeed(assertion, input)
  328. yield* decision
  329. }),
  330. })
  331. const events = Layer.mock(Bus.Service, { subscribe: () => Stream.never })
  332. const it = testEffect(
  333. AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node]), [
  334. [MCP.node, mcp],
  335. [Permission.node, permissions],
  336. [Bus.node, events],
  337. [Image.node, imagePassthrough],
  338. ]),
  339. )
  340. describe("MCP errors", () => {
  341. test("expose useful messages", () => {
  342. expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
  343. expect(
  344. new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message,
  345. ).toBe("failed")
  346. expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
  347. expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
  348. })
  349. })
  350. test("MCP tool names match V1 sanitization", () => {
  351. expect(McpTool.namespace("context 7")).toBe("context_7")
  352. expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
  353. })
  354. test("preserves output schema validation across paginated tool discovery", async () => {
  355. const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
  356. server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
  357. Promise.resolve(
  358. params?.cursor === "page-2"
  359. ? {
  360. tools: [
  361. {
  362. name: "second",
  363. inputSchema: { type: "object" },
  364. outputSchema: {
  365. type: "object",
  366. properties: { value: { type: "number" } },
  367. required: ["value"],
  368. },
  369. },
  370. ],
  371. }
  372. : {
  373. tools: [
  374. {
  375. name: "first",
  376. inputSchema: { type: "object" },
  377. outputSchema: {
  378. type: "object",
  379. properties: { value: { type: "string" } },
  380. required: ["value"],
  381. },
  382. },
  383. ],
  384. nextCursor: "page-2",
  385. },
  386. ),
  387. )
  388. server.setRequestHandler(CallToolRequestSchema, ({ params }) =>
  389. Promise.resolve({
  390. content: [],
  391. structuredContent: { value: params.name === "first" ? 42 : 1 },
  392. }),
  393. )
  394. const client = new Client({ name: "pagination-test", version: "1.0.0" })
  395. const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
  396. await Promise.all([client.connect(clientTransport), server.connect(serverTransport)])
  397. try {
  398. const first = await client.listTools()
  399. const second = await client.listTools({ cursor: first.nextCursor })
  400. expect([...first.tools, ...second.tools].map((tool) => tool.name)).toEqual(["first", "second"])
  401. await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(
  402. "Structured content does not match the tool's output schema",
  403. )
  404. } finally {
  405. await Promise.all([client.close(), server.close()])
  406. }
  407. })
  408. test("retains output schemas across paginated MCP discovery", async () => {
  409. const tools = await Effect.runPromise(
  410. Effect.scoped(
  411. Effect.gen(function* () {
  412. const connection = yield* connect(
  413. "pagination",
  414. new ConfigMCP.Local({
  415. type: "local",
  416. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
  417. }),
  418. import.meta.dir,
  419. )
  420. return yield* connection.tools()
  421. }),
  422. ),
  423. )
  424. expect(tools.map((tool) => ({ name: tool.name, outputSchema: tool.outputSchema }))).toEqual([
  425. {
  426. name: "first",
  427. outputSchema: {
  428. type: "object",
  429. properties: { value: { type: "string" } },
  430. required: ["value"],
  431. },
  432. },
  433. {
  434. name: "second",
  435. outputSchema: {
  436. type: "object",
  437. properties: { value: { type: "number" } },
  438. required: ["value"],
  439. },
  440. },
  441. ])
  442. })
  443. test("spawns local MCP servers through the location environment", async () => {
  444. const spawns: Array<ChildProcess.Command> = []
  445. const cwd = path.join(import.meta.dir, "fixture")
  446. const config = new ConfigMCP.Local({
  447. type: "local",
  448. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
  449. cwd: "fixture",
  450. environment: { MCP_LOCATION_TEST: "configured" },
  451. })
  452. await Effect.runPromise(
  453. Effect.scoped(
  454. Effect.gen(function* () {
  455. const connection = yield* MCPClient.connect("environment", config, import.meta.dir)
  456. yield* connection.tools()
  457. }),
  458. ).pipe(Effect.provide(recordingEnvironmentLayer(spawns))),
  459. )
  460. expect(spawns).toHaveLength(1)
  461. const command = spawns[0]
  462. if (!command || !ChildProcess.isStandardCommand(command)) throw new Error("Expected a standard process command")
  463. expect(command.command).toBe(process.execPath)
  464. expect(command.options.cwd).toBe(cwd)
  465. expect(command.options.extendEnv).toBe(true)
  466. expect(command.options.env).toEqual({ MCP_LOCATION_TEST: "configured" })
  467. })
  468. test("reports a local MCP server as failed when the location has no execution plane", async () => {
  469. const config = new ConfigMCP.Local({ type: "local", command: ["example-mcp"] })
  470. const driver = Environment.makeMemoryDriver()
  471. const environment = Layer.succeed(
  472. Environment.Service,
  473. Environment.Service.of({ files: Environment.makeFiles(driver), spawner: EnvironmentUnavailable.spawner }),
  474. )
  475. await Effect.runPromise(
  476. Effect.gen(function* () {
  477. const service = yield* MCP.Service
  478. yield* service.tools()
  479. const status = (yield* service.servers()).find((server) => server.name === "resources")?.status
  480. expect(status).toEqual({
  481. status: "failed",
  482. error: expect.stringContaining("location has no execution plane"),
  483. })
  484. }).pipe(Effect.provide(resourceMcpLayer(config, undefined, undefined, { environment }))),
  485. )
  486. })
  487. test("rejects sends before the stdio transport is started", async () => {
  488. await Effect.runPromise(
  489. Effect.scoped(
  490. Effect.gen(function* () {
  491. const transport = yield* MCPStdio.make({
  492. server: "not-started",
  493. command: process.execPath,
  494. args: [path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
  495. cwd: import.meta.dir,
  496. environment: {},
  497. })
  498. yield* Effect.tryPromise({
  499. try: () => transport.send({ jsonrpc: "2.0", method: "notifications/initialized" }),
  500. catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
  501. }).pipe(
  502. Effect.flip,
  503. Effect.tap((error) => Effect.sync(() => expect(error.message).toBe("Not connected"))),
  504. )
  505. }).pipe(Effect.provide(hostEnvironmentLayer)),
  506. ),
  507. )
  508. })
  509. test("joins concurrent stdio transport closes", async () => {
  510. await Effect.runPromise(
  511. Effect.scoped(
  512. Effect.gen(function* () {
  513. const transport = yield* MCPStdio.make({
  514. server: "concurrent-close",
  515. command: "unused",
  516. args: [],
  517. cwd: import.meta.dir,
  518. environment: {},
  519. })
  520. const first = transport.close()
  521. expect(transport.close()).toBe(first)
  522. yield* Effect.promise(() => first)
  523. }).pipe(Effect.provide(hostEnvironmentLayer)),
  524. ),
  525. )
  526. })
  527. test("closes a stdio process that finishes spawning after close", async () => {
  528. const spawning = Deferred.makeUnsafe<void>()
  529. const release = Deferred.makeUnsafe<void>()
  530. const exited = Deferred.makeUnsafe<ExitCode>()
  531. const signals: Array<string> = []
  532. const driver = Environment.makeMemoryDriver()
  533. const environment = Layer.succeed(
  534. Environment.Service,
  535. Environment.Service.of({
  536. files: Environment.makeFiles(driver),
  537. spawner: ChildProcessSpawner.make(() =>
  538. Effect.gen(function* () {
  539. yield* Deferred.succeed(spawning, undefined)
  540. yield* Deferred.await(release)
  541. return makeHandle({
  542. pid: ProcessId(1),
  543. exitCode: Deferred.await(exited),
  544. isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)),
  545. kill: (options) =>
  546. Effect.gen(function* () {
  547. signals.push(options?.killSignal ?? "SIGTERM")
  548. yield* Deferred.succeed(exited, ExitCode(143))
  549. }),
  550. stdin: Sink.drain,
  551. stdout: Stream.never,
  552. stderr: Stream.empty,
  553. all: Stream.never,
  554. getInputFd: () => Sink.drain,
  555. getOutputFd: () => Stream.empty,
  556. unref: Effect.succeed(Effect.void),
  557. })
  558. }),
  559. ),
  560. }),
  561. )
  562. await Effect.runPromise(
  563. Effect.scoped(
  564. Effect.gen(function* () {
  565. const transport = yield* MCPStdio.make({
  566. server: "close-during-spawn",
  567. command: "unused",
  568. args: [],
  569. cwd: import.meta.dir,
  570. environment: {},
  571. })
  572. const start = transport.start()
  573. yield* Deferred.await(spawning)
  574. const close = transport.close()
  575. yield* Deferred.succeed(release, undefined)
  576. yield* Effect.promise(() => Promise.all([start, close]))
  577. }).pipe(Effect.provide(environment)),
  578. ),
  579. )
  580. expect(signals).toEqual(["SIGTERM"])
  581. })
  582. test("applies the configured MCP catalog timeout", async () => {
  583. const result = Effect.runPromise(
  584. Effect.scoped(
  585. Effect.gen(function* () {
  586. const connection = yield* connect(
  587. "catalog-timeout",
  588. new ConfigMCP.Local({
  589. type: "local",
  590. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
  591. environment: { MCP_TIMEOUT_TARGET: "catalog" },
  592. timeout: new ConfigMCP.Timeout({ catalog: 10 }),
  593. }),
  594. import.meta.dir,
  595. )
  596. return yield* connection.tools()
  597. }),
  598. ),
  599. )
  600. await expect(result).rejects.toThrow("Request timed out")
  601. })
  602. test("applies the configured MCP execution timeout", async () => {
  603. const result = Effect.runPromise(
  604. Effect.scoped(
  605. Effect.gen(function* () {
  606. const connection = yield* connect(
  607. "execution-timeout",
  608. new ConfigMCP.Local({
  609. type: "local",
  610. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
  611. timeout: new ConfigMCP.Timeout({ execution: 10 }),
  612. }),
  613. import.meta.dir,
  614. )
  615. return yield* connection.callTool({ name: "slow" })
  616. }),
  617. ),
  618. )
  619. await expect(result).rejects.toThrow("Request timed out")
  620. })
  621. test("applies the configured MCP execution timeout to prompts", async () => {
  622. const result = Effect.runPromise(
  623. Effect.scoped(
  624. Effect.gen(function* () {
  625. const connection = yield* connect(
  626. "prompt-timeout",
  627. new ConfigMCP.Local({
  628. type: "local",
  629. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
  630. timeout: new ConfigMCP.Timeout({ execution: 10 }),
  631. }),
  632. import.meta.dir,
  633. )
  634. return yield* connection.prompt({ name: "slow" })
  635. }),
  636. ),
  637. )
  638. await expect(result).rejects.toThrow("Request timed out")
  639. })
  640. test("applies configured MCP timeouts to resource operations", async () => {
  641. const catalog = Effect.runPromise(
  642. Effect.scoped(
  643. Effect.gen(function* () {
  644. const connection = yield* connect(
  645. "resource-catalog-timeout",
  646. new ConfigMCP.Local({
  647. type: "local",
  648. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
  649. environment: { MCP_TIMEOUT_TARGET: "resource-catalog" },
  650. timeout: new ConfigMCP.Timeout({ catalog: 10 }),
  651. }),
  652. import.meta.dir,
  653. )
  654. return yield* connection.resources()
  655. }),
  656. ),
  657. )
  658. await expect(catalog).rejects.toThrow("Request timed out")
  659. const read = Effect.runPromise(
  660. Effect.scoped(
  661. Effect.gen(function* () {
  662. const connection = yield* connect(
  663. "resource-read-timeout",
  664. new ConfigMCP.Local({
  665. type: "local",
  666. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
  667. timeout: new ConfigMCP.Timeout({ execution: 10 }),
  668. }),
  669. import.meta.dir,
  670. )
  671. return yield* connection.readResource({ uri: "test://slow" })
  672. }),
  673. ),
  674. )
  675. await expect(read).rejects.toThrow("Request timed out")
  676. })
  677. test("lists, reads, and reports MCP resource changes", async () => {
  678. await Effect.runPromise(
  679. Effect.scoped(
  680. Effect.gen(function* () {
  681. const server = yield* resourceServer({ listChanged: true })
  682. server.state.resourcePages = {
  683. initial: {
  684. items: [{ name: "Readme", uri: "docs://readme", description: "Project docs" }],
  685. nextCursor: "resources-2",
  686. },
  687. "resources-2": { items: [{ name: "Logo", uri: "docs://logo", mimeType: "image/png" }] },
  688. }
  689. server.state.templatePages = {
  690. initial: {
  691. items: [{ name: "File", uriTemplate: "docs://{path}" }],
  692. nextCursor: "templates-2",
  693. },
  694. "templates-2": { items: [{ name: "Issue", uriTemplate: "issue://{id}", description: "Issue" }] },
  695. }
  696. const connection = yield* connect(
  697. "resources",
  698. new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
  699. import.meta.dir,
  700. )
  701. expect(yield* connection.resources()).toEqual([
  702. { name: "Readme", uri: "docs://readme", description: "Project docs", mimeType: undefined },
  703. { name: "Logo", uri: "docs://logo", description: undefined, mimeType: "image/png" },
  704. ])
  705. expect(yield* connection.resourceTemplates()).toEqual([
  706. { name: "File", uriTemplate: "docs://{path}", description: undefined, mimeType: undefined },
  707. { name: "Issue", uriTemplate: "issue://{id}", description: "Issue", mimeType: undefined },
  708. ])
  709. expect(yield* connection.readResource({ uri: "docs://readme" })).toEqual({
  710. contents: [
  711. { type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
  712. { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
  713. ],
  714. })
  715. const changed = yield* Deferred.make<void>()
  716. connection.onResourcesChanged(() => Deferred.doneUnsafe(changed, Exit.void))
  717. yield* Effect.promise(server.sendResourceListChanged)
  718. yield* Deferred.await(changed)
  719. }),
  720. ),
  721. )
  722. })
  723. test("does not reconnect an SSE stream after a JSON-RPC error response", async () => {
  724. let requests = 0
  725. const transport = new StreamableHTTPClientTransport(new URL("http://mcp.invalid"), {
  726. fetch: async () => {
  727. requests += 1
  728. return new Response(
  729. new ReadableStream({
  730. start(controller) {
  731. controller.enqueue(new TextEncoder().encode("id: prime\nretry: 1\ndata:\n\n"))
  732. controller.enqueue(
  733. new TextEncoder().encode(
  734. 'id: error\ndata: {"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}\n\n',
  735. ),
  736. )
  737. controller.close()
  738. },
  739. }),
  740. { status: 200, headers: { "content-type": "text/event-stream" } },
  741. )
  742. },
  743. reconnectionOptions: {
  744. initialReconnectionDelay: 1,
  745. maxReconnectionDelay: 1,
  746. reconnectionDelayGrowFactor: 1,
  747. maxRetries: 2,
  748. },
  749. })
  750. await transport.start()
  751. await transport.send({ jsonrpc: "2.0", method: "resources/list", id: 1 })
  752. await Bun.sleep(25)
  753. await transport.close()
  754. expect(requests).toBe(1)
  755. })
  756. test("skips MCP resource requests when the capability is absent", async () => {
  757. await Effect.runPromise(
  758. Effect.scoped(
  759. Effect.gen(function* () {
  760. const server = yield* resourceServer({ resources: false })
  761. const connection = yield* connect(
  762. "resources",
  763. new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
  764. import.meta.dir,
  765. )
  766. expect(yield* connection.resources()).toEqual([])
  767. expect(yield* connection.resourceTemplates()).toEqual([])
  768. expect(yield* connection.readResource({ uri: "docs://readme" })).toBeUndefined()
  769. expect({ resources: server.state.resourceLists, templates: server.state.templateLists }).toEqual({
  770. resources: 0,
  771. templates: 0,
  772. })
  773. }),
  774. ),
  775. )
  776. })
  777. test("accepts empty MCP elicitations without creating forms", async () => {
  778. await Effect.runPromise(
  779. Effect.scoped(
  780. Effect.gen(function* () {
  781. const server = yield* resourceServer({ resources: false, emptyElicitation: true })
  782. const result = yield* Effect.gen(function* () {
  783. const service = yield* MCP.Service
  784. const forms = yield* Form.Service
  785. const result = yield* service.callTool({ server: "resources", name: "empty-elicitation" })
  786. expect(yield* forms.list()).toEqual([])
  787. return result
  788. }).pipe(Effect.provide(resourceMcpLayer(server.url)))
  789. expect(result.structured).toEqual({ action: "accept", content: {} })
  790. }),
  791. ),
  792. )
  793. })
  794. test("acknowledges completed MCP URL elicitations without returning internal content", async () => {
  795. await Effect.runPromise(
  796. Effect.scoped(
  797. Effect.gen(function* () {
  798. const server = yield* resourceServer({ resources: false, urlElicitation: true })
  799. const created = yield* Deferred.make<Form.Info>()
  800. const result = yield* Effect.gen(function* () {
  801. const service = yield* MCP.Service
  802. const forms = yield* Form.Service
  803. const call = yield* service.callTool({ server: "resources", name: "url-elicitation" }).pipe(Effect.forkScoped)
  804. const form = yield* Deferred.await(created)
  805. expect(form.fields).toEqual([{ key: "elicitation", type: "external", url: "https://example.com/authorize" }])
  806. yield* Effect.promise(server.completeElicitation)
  807. const result = yield* Fiber.join(call)
  808. expect(yield* forms.state(form.id)).toEqual({ status: "answered", answer: { elicitation: true } })
  809. return result
  810. }).pipe(
  811. Effect.provide(resourceMcpLayer(server.url, (form) => Deferred.succeed(created, form).pipe(Effect.asVoid))),
  812. )
  813. expect(result.structured).toEqual({ action: "accept" })
  814. }),
  815. ),
  816. )
  817. })
  818. test("loads and reads MCP resources", async () => {
  819. await Effect.runPromise(
  820. Effect.scoped(
  821. Effect.gen(function* () {
  822. const server = yield* resourceServer()
  823. server.state.resources = [{ name: "Readme", uri: "docs://readme" }]
  824. server.state.templates = [{ name: "File", uriTemplate: "docs://{path}" }]
  825. yield* Effect.gen(function* () {
  826. const service = yield* MCP.Service
  827. expect(yield* service.resourceCatalog()).toEqual({
  828. resources: [
  829. {
  830. server: "resources",
  831. name: "Readme",
  832. uri: "docs://readme",
  833. description: undefined,
  834. mimeType: undefined,
  835. },
  836. ],
  837. templates: [
  838. {
  839. server: "resources",
  840. name: "File",
  841. uriTemplate: "docs://{path}",
  842. description: undefined,
  843. mimeType: undefined,
  844. },
  845. ],
  846. })
  847. server.state.resources = [{ name: "Guide", uri: "docs://guide" }]
  848. expect((yield* service.resourceCatalog()).resources.map((resource) => resource.uri)).toEqual(["docs://guide"])
  849. expect(yield* service.readResource({ server: "resources", uri: "docs://readme" })).toEqual({
  850. server: "resources",
  851. uri: "docs://readme",
  852. contents: [
  853. { type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
  854. { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
  855. ],
  856. })
  857. expect(server.clientVersion()).toMatchObject({ name: "sdk", version: "1.2.3" })
  858. }).pipe(
  859. Effect.provide(resourceMcpLayer(server.url, undefined, { clientInfo: { name: "sdk", version: "1.2.3" } })),
  860. )
  861. }),
  862. ),
  863. )
  864. })
  865. test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
  866. await Effect.runPromise(
  867. Effect.scoped(
  868. Effect.gen(function* () {
  869. yield* Effect.gen(function* () {
  870. const service = yield* MCP.Service
  871. expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
  872. expect(yield* service.connect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
  873. expect(yield* service.disconnect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
  874. yield* service.add(
  875. "dynamic",
  876. new ConfigMCP.Local({
  877. type: "local",
  878. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
  879. }),
  880. )
  881. expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
  882. status: "connected",
  883. })
  884. yield* service.add(
  885. "dynamic",
  886. new ConfigMCP.Local({
  887. type: "local",
  888. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
  889. disabled: true,
  890. }),
  891. )
  892. expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
  893. status: "disabled",
  894. })
  895. expect(yield* service.tools()).toEqual([])
  896. yield* service.connect("dynamic")
  897. expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
  898. status: "connected",
  899. })
  900. yield* service.disconnect("dynamic")
  901. expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
  902. status: "disabled",
  903. })
  904. expect(yield* service.tools()).toEqual([])
  905. yield* service.connect("dynamic")
  906. expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
  907. status: "connected",
  908. })
  909. yield* service.remove("dynamic")
  910. expect((yield* service.servers()).some((server) => server.name === "dynamic")).toBe(false)
  911. expect(yield* service.tools()).toEqual([])
  912. expect(yield* service.remove("dynamic").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
  913. }).pipe(
  914. Effect.provide(
  915. resourceMcpLayer(
  916. new ConfigMCP.Local({
  917. type: "local",
  918. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
  919. disabled: true,
  920. }),
  921. ),
  922. ),
  923. )
  924. }),
  925. ),
  926. )
  927. })
  928. test("reconciles only changed MCP server config", async () => {
  929. await Effect.runPromise(
  930. Effect.scoped(
  931. Effect.gen(function* () {
  932. const server = yield* resourceServer()
  933. const updates = yield* PubSub.unbounded<Payload>()
  934. const resources = (codemode?: boolean) =>
  935. new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, codemode })
  936. const added = new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true })
  937. const dynamic = new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true })
  938. const document = (servers: Record<string, typeof ConfigMCP.Server.Type>, username?: string) =>
  939. new Document({
  940. type: "document",
  941. info: new Info({
  942. username,
  943. mcp: new ConfigMCP.Info({ servers }),
  944. }),
  945. })
  946. let entries = [document({ resources: resources() })]
  947. const publishUpdate = () =>
  948. PubSub.publish(updates, {
  949. id: ID.create(),
  950. created: 0,
  951. type: Event.Updated.type,
  952. data: {},
  953. } satisfies Payload<typeof Event.Updated>)
  954. yield* Effect.gen(function* () {
  955. const service = yield* MCP.Service
  956. yield* service.tools()
  957. expect(server.state.toolLists).toBe(1)
  958. expect(server.state.initializations).toBe(1)
  959. yield* service.add("dynamic", dynamic)
  960. entries = [document({ resources: resources() }, "unrelated")]
  961. yield* publishUpdate()
  962. entries = [document({ resources: resources(), added }, "unrelated")]
  963. yield* publishUpdate()
  964. const appended = yield* service.servers().pipe(
  965. Effect.filterOrFail(
  966. (items) => items.some((item) => item.name === "added"),
  967. () => new Error("MCP config addition was not applied"),
  968. ),
  969. Effect.retry({ times: 100, schedule: Schedule.spaced("10 millis") }),
  970. )
  971. expect(appended.map((item) => String(item.name)).toSorted()).toEqual(["added", "dynamic", "resources"])
  972. expect(server.state.toolLists).toBe(1)
  973. expect(server.state.initializations).toBe(1)
  974. entries = [
  975. document(
  976. {
  977. resources: resources(false),
  978. added,
  979. },
  980. "unrelated",
  981. ),
  982. ]
  983. yield* publishUpdate()
  984. yield* Effect.sync(() => server.state.initializations).pipe(
  985. Effect.filterOrFail(
  986. (count) => count === 2,
  987. () => new Error("MCP config change did not reconnect the server"),
  988. ),
  989. Effect.retry({ times: 100, schedule: Schedule.spaced("10 millis") }),
  990. )
  991. entries = [document({ added }, "unrelated")]
  992. yield* publishUpdate()
  993. const removed = yield* service.servers().pipe(
  994. Effect.filterOrFail(
  995. (items) => !items.some((item) => item.name === "resources"),
  996. () => new Error("MCP config removal was not applied"),
  997. ),
  998. Effect.retry({ times: 100, schedule: Schedule.spaced("10 millis") }),
  999. )
  1000. expect(removed.map((item) => String(item.name)).toSorted()).toEqual(["added", "dynamic"])
  1001. }).pipe(
  1002. Effect.provide(
  1003. resourceMcpLayer(resources(), undefined, undefined, {
  1004. entries: () => Effect.sync(() => entries),
  1005. subscribe: (() => Stream.fromPubSub(updates)) as Bus.Interface["subscribe"],
  1006. }),
  1007. ),
  1008. )
  1009. }),
  1010. ),
  1011. )
  1012. })
  1013. test("reconciles MCP config changed during startup", async () => {
  1014. const server = new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true })
  1015. let reads = 0
  1016. const entries = () =>
  1017. Effect.sync(() => {
  1018. reads += 1
  1019. return [
  1020. new Document({
  1021. type: "document",
  1022. info: new Info({
  1023. mcp: new ConfigMCP.Info({
  1024. servers: reads === 1 ? { initial: server } : { initial: server, added: server },
  1025. }),
  1026. }),
  1027. }),
  1028. ]
  1029. })
  1030. await Effect.runPromise(
  1031. Effect.scoped(
  1032. Effect.gen(function* () {
  1033. const service = yield* MCP.Service
  1034. expect((yield* service.servers()).map((item) => String(item.name))).toEqual(["added", "initial"])
  1035. expect(reads).toBeGreaterThanOrEqual(2)
  1036. }).pipe(Effect.provide(resourceMcpLayer(server, undefined, undefined, { entries }))),
  1037. ),
  1038. )
  1039. })
  1040. test("serializes concurrent MCP lifecycle operations", async () => {
  1041. await Effect.runPromise(
  1042. Effect.scoped(
  1043. Effect.gen(function* () {
  1044. yield* Effect.gen(function* () {
  1045. const service = yield* MCP.Service
  1046. // Whatever order the racing operations land in, the resulting state must be consistent.
  1047. yield* Effect.all(
  1048. [
  1049. service.connect("resources"),
  1050. service.connect("resources"),
  1051. service.disconnect("resources"),
  1052. service.connect("resources"),
  1053. ],
  1054. { concurrency: "unbounded", discard: true },
  1055. )
  1056. const status = (yield* service.servers()).find((server) => server.name === "resources")?.status
  1057. const tools = yield* service.tools()
  1058. expect(status?.status === "connected" || status?.status === "disabled").toBe(true)
  1059. if (status?.status === "disabled") expect(tools).toEqual([])
  1060. if (status?.status === "connected") expect(tools.length).toBeGreaterThan(0)
  1061. yield* service.disconnect("resources")
  1062. expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
  1063. expect(yield* service.tools()).toEqual([])
  1064. yield* service.connect("resources")
  1065. expect((yield* service.servers())[0]?.status).toEqual({ status: "connected" })
  1066. expect((yield* service.tools()).length).toBeGreaterThan(0)
  1067. }).pipe(
  1068. Effect.provide(
  1069. resourceMcpLayer(
  1070. new ConfigMCP.Local({
  1071. type: "local",
  1072. command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
  1073. disabled: true,
  1074. }),
  1075. ),
  1076. ),
  1077. )
  1078. }),
  1079. ),
  1080. )
  1081. })
  1082. it.effect("advertises MCP output schemas to Code Mode", () =>
  1083. Effect.gen(function* () {
  1084. const registry = yield* Tool.Service
  1085. const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
  1086. const execute = toolSet.definitions.find((tool) => tool.name === "execute")
  1087. expect(toolSet.definitions.map((tool) => tool.name)).toEqual([
  1088. "direct_fail",
  1089. "direct_lookup",
  1090. "direct_media",
  1091. "execute",
  1092. ])
  1093. expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).toContain("ok: boolean")
  1094. expect(execute?.description).not.toContain("tools.demo.search")
  1095. }),
  1096. )
  1097. it.effect("returns content-only MCP results through Code Mode", () =>
  1098. Effect.gen(function* () {
  1099. assertion = yield* Deferred.make<Permission.AssertInput>()
  1100. decision = Effect.void
  1101. const registry = yield* Tool.Service
  1102. const toolSet = yield* waitForCodeModeTool(registry, "demo.status")
  1103. const execution = yield* toolSet.execute({
  1104. sessionID: Session.ID.make("ses_mcp_content_only"),
  1105. ...toolIdentity,
  1106. call: {
  1107. type: "tool-call",
  1108. id: "call_mcp_content_only",
  1109. name: "execute",
  1110. input: { code: "return await tools.demo.status({})" },
  1111. },
  1112. })
  1113. expect(execution).toMatchObject({
  1114. output: { output: "hello", toolCalls: [{ tool: "demo.status", status: "completed" }] },
  1115. content: [{ type: "text", text: "hello" }],
  1116. })
  1117. }),
  1118. )
  1119. it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
  1120. Effect.gen(function* () {
  1121. const registry = yield* Tool.Service
  1122. yield* waitForTool(registry, "direct_lookup")
  1123. const definitions = yield* toolDefinitions(registry)
  1124. const execute = definitions.find((tool) => tool.name === "execute")
  1125. expect(definitions.some((tool) => tool.name === "direct_lookup")).toBe(true)
  1126. expect(execute?.description).not.toContain("tools.direct.lookup")
  1127. }),
  1128. )
  1129. // Baseline (PLAN.md step 1): MCP isError must become one failed tool call, not a
  1130. // success whose text happens to describe an error.
  1131. it.effect("fails the call when MCP reports isError", () =>
  1132. Effect.gen(function* () {
  1133. assertion = yield* Deferred.make<Permission.AssertInput>()
  1134. decision = Effect.void
  1135. const registry = yield* Tool.Service
  1136. yield* waitForTool(registry, "direct_fail")
  1137. const execution = yield* executeTool(registry, {
  1138. sessionID: Session.ID.make("ses_mcp_is_error"),
  1139. ...toolIdentity,
  1140. call: { type: "tool-call", id: "call_mcp_is_error", name: "direct_fail", input: {} },
  1141. })
  1142. expect(execution).toMatchObject({ status: "error", error: { message: "search index unavailable" } })
  1143. }),
  1144. )
  1145. // Baseline (PLAN.md step 1): mixed MCP text and media content must reach the model intact.
  1146. it.effect("preserves MCP text and media content for the model", () =>
  1147. Effect.gen(function* () {
  1148. assertion = yield* Deferred.make<Permission.AssertInput>()
  1149. decision = Effect.void
  1150. const registry = yield* Tool.Service
  1151. yield* waitForTool(registry, "direct_media")
  1152. const execution = yield* executeTool(registry, {
  1153. sessionID: Session.ID.make("ses_mcp_media"),
  1154. ...toolIdentity,
  1155. call: { type: "tool-call", id: "call_mcp_media", name: "direct_media", input: {} },
  1156. })
  1157. expect(execution.output).toBe("rendered chart")
  1158. expect(execution.content).toMatchObject([
  1159. { type: "text", text: "rendered chart" },
  1160. { type: "file", mime: "image/png" },
  1161. ])
  1162. }),
  1163. )
  1164. it.effect("waits for permission before calling an MCP tool", () =>
  1165. Effect.gen(function* () {
  1166. calls = 0
  1167. assertion = yield* Deferred.make<Permission.AssertInput>()
  1168. const permission = yield* Deferred.make<void>()
  1169. decision = Deferred.await(permission)
  1170. const registry = yield* Tool.Service
  1171. const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
  1172. const fiber = yield* toolSet
  1173. .execute({
  1174. sessionID: Session.ID.make("ses_mcp_permission"),
  1175. ...toolIdentity,
  1176. call: {
  1177. type: "tool-call",
  1178. id: "call_mcp_permission",
  1179. name: "execute",
  1180. input: { code: "return await tools.demo.search({})" },
  1181. },
  1182. })
  1183. .pipe(Effect.forkScoped)
  1184. expect(yield* Deferred.await(assertion)).toEqual({
  1185. action: "demo_search",
  1186. resources: ["*"],
  1187. save: ["*"],
  1188. metadata: {},
  1189. sessionID: Session.ID.make("ses_mcp_permission"),
  1190. agent: toolIdentity.agent,
  1191. source: {
  1192. type: "tool",
  1193. messageID: toolIdentity.messageID,
  1194. id: "call_mcp_permission",
  1195. },
  1196. })
  1197. expect(calls).toBe(0)
  1198. yield* Deferred.succeed(permission, undefined)
  1199. yield* Fiber.join(fiber)
  1200. expect(calls).toBe(1)
  1201. }),
  1202. )
  1203. it.effect("does not call MCP when permission is blocked", () =>
  1204. Effect.gen(function* () {
  1205. calls = 0
  1206. assertion = yield* Deferred.make<Permission.AssertInput>()
  1207. decision = Effect.fail(new Permission.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
  1208. const registry = yield* Tool.Service
  1209. const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
  1210. const execution = yield* toolSet.execute({
  1211. sessionID: Session.ID.make("ses_mcp_blocked"),
  1212. ...toolIdentity,
  1213. call: {
  1214. type: "tool-call",
  1215. id: "call_mcp_blocked",
  1216. name: "execute",
  1217. input: { code: "return await tools.demo.search({})" },
  1218. },
  1219. })
  1220. expect(execution.content).toEqual([{ type: "text", text: "Unable to execute demo_search" }])
  1221. expect(execution.metadata).toEqual({
  1222. toolCalls: [{ tool: "demo.search", status: "error" }],
  1223. error: true,
  1224. })
  1225. expect(calls).toBe(0)
  1226. }),
  1227. )