mcp.test.ts 22 KB

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