History

fix elicitation

Rhys Sullivan committed 7 months ago

Commit 4cd93121 parent 641e4aa8 files · +869 −25Browse files

Unified · Split

8 files
  1. .changeset/solid-colts-sink.md +5 −0
  2. .mcp.json +3 −1
  3. bun.lock +3 −0
  4. packages/core/execution/src/engine.ts +9 −20
  5. packages/hosts/mcp/package.json +7 −2
  6. packages/hosts/mcp/src/server.test.ts +828 −0
  7. packages/hosts/mcp/src/server.ts +7 −2
  8. packages/hosts/mcp/vitest.config.ts +7 −0
.changeset/solid-colts-sink.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"executor": patch
3+---
4+
5+fix elicitation
.mcp.json+3−1
@@ -6,7 +6,9 @@ · 5 unmodified lines
66 "run",
77 "/Users/rhyssullivan/src/v4-executor/apps/cli/src/main.ts",
88 "mcp"
9− ]
9+ ],
10+ "type": "http",
11+ "url": "http://localhost:5173/mcp"
1012 }
1113 }
1214 }
bun.lock+3−0
@@ -204,8 +204,11 @@ · 203 unmodified lines
204204 "zod": "^4.3.0",
205205 },
206206 "devDependencies": {
207+ "@effect/vitest": "catalog:",
208+ "@executor/codemode-core": "workspace:*",
207209 "@types/node": "catalog:",
208210 "bun-types": "catalog:",
211+ "vitest": "catalog:",
209212 },
210213 },
211214 "packages/kernel/core": {
packages/core/execution/src/engine.ts+9−20
@@ -295,7 +295,11 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE · 294 unmodified lines
295295 },
296296
297297 executeWithPause: async (code) => {
298− let pausedResolve: PausedExecution | null = null;
298+ // Signal from the elicitation handler to the race below.
299+ let signalPause: ((paused: PausedExecution) => void) | null = null;
300+ const pausePromise = new Promise<PausedExecution>((resolve) => {
301+ signalPause = resolve;
302+ });
299303
300304 const elicitationHandler: ElicitationHandler = (ctx: ElicitationContext) =>
301305 Effect.async<typeof ElicitationResponse.Type>((resume) => {
@@ -306,32 +310,17 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE · 4 unmodified lines
306310 resolve: (response) => resume(Effect.succeed(response)),
307311 completion: undefined as unknown as Promise<ExecuteResult>,
308312 };
309− pausedResolve = paused;
310313 pausedExecutions.set(id, paused);
314+ signalPause!(paused);
311315 });
312316
313317 const invoker = makeFullInvoker(executor, { onElicitation: elicitationHandler });
314318 const completionPromise = runEffect(codeExecutor.execute(code, invoker));
315319
316− if (pausedResolve) {
317− (pausedResolve as { completion: Promise<ExecuteResult> }).completion = completionPromise;
318− }
319−
320320 // Race: either the execution completes, or it pauses for elicitation.
321− // Poll briefly to detect pauses that fire asynchronously.
322321 const result = await Promise.race([
323322 completionPromise.then((r) => ({ kind: "completed" as const, result: r })),
324− new Promise<{ kind: "poll" }>((resolve) => {
325− const check = (attempt: number) => {
326− if (pausedResolve) {
327− resolve({ kind: "poll" });
328− } else if (attempt < 100) {
329− setTimeout(() => check(attempt + 1), 50);
330− }
331− };
332− // Start after a tick to let sync elicitations fire first
333− setTimeout(() => check(0), 0);
334− }),
323+ pausePromise.then((p) => ({ kind: "paused" as const, execution: p })),
335324 ]);
336325
337326 if (result.kind === "completed") {
@@ -339,8 +328,8 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE · 1 unmodified line
339328 }
340329
341330 // Execution paused — attach the completion promise and return
342− (pausedResolve as unknown as { completion: Promise<ExecuteResult> }).completion = completionPromise;
343− return { status: "paused", execution: pausedResolve! };
331+ (result.execution as { completion: Promise<ExecuteResult> }).completion = completionPromise;
332+ return { status: "paused", execution: result.execution };
344333 },
345334
346335 resume: async (executionId, response) => {
packages/hosts/mcp/package.json+7−2
@@ -7,7 +7,9 @@ · 6 unmodified lines
77 ".": "./src/index.ts"
88 },
99 "scripts": {
10− "typecheck": "bunx tsc --noEmit -p tsconfig.json"
10+ "typecheck": "bunx tsc --noEmit -p tsconfig.json",
11+ "test": "vitest run",
12+ "test:watch": "vitest"
1113 },
1214 "dependencies": {
1315 "@executor/sdk": "workspace:*",
@@ -17,7 +19,10 @@ · 3 unmodified lines
1719 "zod": "^4.3.0"
1820 },
1921 "devDependencies": {
22+ "@effect/vitest": "catalog:",
23+ "@executor/codemode-core": "workspace:*",
2024 "@types/node": "catalog:",
21− "bun-types": "catalog:"
25+ "bun-types": "catalog:",
26+ "vitest": "catalog:"
2227 }
2328 }
packages/hosts/mcp/src/server.test.ts+828−0
@@ -0,0 +1,828 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4+import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
5+import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6+
7+import {
8+ FormElicitation,
9+ UrlElicitation,
10+ ElicitationResponse,
11+ type ElicitationHandler,
12+ type ElicitationContext,
13+} from "@executor/sdk";
14+import type { ExecutionEngine, ExecutionResult, PausedExecution } from "@executor/execution";
15+import type { ExecuteResult } from "@executor/codemode-core";
16+
17+import { createExecutorMcpServer } from "./server";
18+
19+// ---------------------------------------------------------------------------
20+// Helpers — stub engine
21+// ---------------------------------------------------------------------------
22+
23+/**
24+ * Creates a fake ExecutionEngine where `execute` and `executeWithPause`
25+ * call into caller-provided functions so each test can control behaviour.
26+ */
27+const makeStubEngine = (overrides: {
28+ execute?: ExecutionEngine["execute"];
29+ executeWithPause?: ExecutionEngine["executeWithPause"];
30+ resume?: ExecutionEngine["resume"];
31+ description?: string;
32+}): ExecutionEngine => ({
33+ execute: overrides.execute ?? (async () => ({ result: "default" })),
34+ executeWithPause: overrides.executeWithPause ??
35+ (async () => ({ status: "completed", result: { result: "default" } })),
36+ resume: overrides.resume ?? (async () => null),
37+ getDescription: async () => overrides.description ?? "test executor",
38+});
39+
40+// ---------------------------------------------------------------------------
41+// Helpers — spin up in-memory client ↔ server
42+// ---------------------------------------------------------------------------
43+
44+type TestHarness = {
45+ client: Client;
46+ close: () => Promise<void>;
47+};
48+
49+/**
50+ * Connect a real MCP Client to our executor MCP server over in-memory
51+ * transports. The `clientCapabilities` parameter controls whether the
52+ * client advertises elicitation support.
53+ */
54+const connect = async (
55+ engine: ExecutionEngine,
56+ clientCapabilities: { elicitation?: { form?: object } } = {},
57+): Promise<TestHarness> => {
58+ const mcpServer = await createExecutorMcpServer({ engine });
59+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
60+
61+ const client = new Client(
62+ { name: "test-client", version: "1.0.0" },
63+ { capabilities: clientCapabilities },
64+ );
65+
66+ await mcpServer.connect(serverTransport);
67+ await client.connect(clientTransport);
68+
69+ return {
70+ client,
71+ close: async () => {
72+ await clientTransport.close();
73+ await serverTransport.close();
74+ },
75+ };
76+};
77+
78+// ---------------------------------------------------------------------------
79+// Tests — client WITH elicitation support (managed / inline path)
80+// ---------------------------------------------------------------------------
81+
82+describe("MCP host server — client with elicitation", () => {
83+ it.effect(
84+ "execute tool calls engine.execute and returns result",
85+ () =>
86+ Effect.promise(async () => {
87+ const engine = makeStubEngine({
88+ execute: async (code, { onElicitation }) => ({
89+ result: `ran: ${code}`,
90+ }),
91+ });
92+
93+ const { client, close } = await connect(engine, {
94+ elicitation: { form: {} },
95+ });
96+
97+ try {
98+ const result = await client.callTool({ name: "execute", arguments: { code: "1+1" } });
99+ expect(result.content).toEqual([{ type: "text", text: "ran: 1+1" }]);
100+ expect(result.isError).toBeFalsy();
101+ } finally {
102+ await close();
103+ }
104+ }),
105+ );
106+
107+ it.effect(
108+ "form elicitation is bridged from engine to MCP client and back",
109+ () =>
110+ Effect.promise(async () => {
111+ const engine = makeStubEngine({
112+ execute: async (code, { onElicitation }) => {
113+ const response = await Effect.runPromise(
114+ onElicitation({
115+ toolId: "test-tool" as any,
116+ args: { code },
117+ request: new FormElicitation({
118+ message: "Approve this action?",
119+ requestedSchema: {
120+ type: "object",
121+ properties: {
122+ approved: { type: "boolean" },
123+ },
124+ },
125+ }),
126+ }),
127+ );
128+ return {
129+ result:
130+ response.action === "accept" && response.content?.approved
131+ ? "approved"
132+ : "denied",
133+ };
134+ },
135+ });
136+
137+ const { client, close } = await connect(engine, {
138+ elicitation: { form: {} },
139+ });
140+
141+ // Register a client-side handler that auto-accepts
142+ client.setRequestHandler(ElicitRequestSchema, async (request) => ({
143+ action: "accept" as const,
144+ content: { approved: true },
145+ }));
146+
147+ try {
148+ const result = await client.callTool({
149+ name: "execute",
150+ arguments: { code: "do-it" },
151+ });
152+ expect(result.content).toEqual([{ type: "text", text: "approved" }]);
153+ } finally {
154+ await close();
155+ }
156+ }),
157+ );
158+
159+ it.effect(
160+ "form elicitation declined by client → engine sees decline",
161+ () =>
162+ Effect.promise(async () => {
163+ const engine = makeStubEngine({
164+ execute: async (code, { onElicitation }) => {
165+ const response = await Effect.runPromise(
166+ onElicitation({
167+ toolId: "t" as any,
168+ args: {},
169+ request: new FormElicitation({
170+ message: "Accept?",
171+ requestedSchema: {},
172+ }),
173+ }),
174+ );
175+ return { result: `action:${response.action}` };
176+ },
177+ });
178+
179+ const { client, close } = await connect(engine, {
180+ elicitation: { form: {} },
181+ });
182+
183+ client.setRequestHandler(ElicitRequestSchema, async () => ({
184+ action: "decline" as const,
185+ content: {},
186+ }));
187+
188+ try {
189+ const result = await client.callTool({
190+ name: "execute",
191+ arguments: { code: "x" },
192+ });
193+ expect(result.content).toEqual([
194+ { type: "text", text: "action:decline" },
195+ ]);
196+ } finally {
197+ await close();
198+ }
199+ }),
200+ );
201+
202+ it.effect(
203+ "empty form schema gets wrapped with minimal valid schema",
204+ () =>
205+ Effect.promise(async () => {
206+ let receivedSchema: unknown;
207+
208+ const engine = makeStubEngine({
209+ execute: async (_code, { onElicitation }) => {
210+ const response = await Effect.runPromise(
211+ onElicitation({
212+ toolId: "t" as any,
213+ args: {},
214+ request: new FormElicitation({
215+ message: "Just approve",
216+ requestedSchema: {}, // empty — approval only
217+ }),
218+ }),
219+ );
220+ return { result: response.action };
221+ },
222+ });
223+
224+ const { client, close } = await connect(engine, {
225+ elicitation: { form: {} },
226+ });
227+
228+ client.setRequestHandler(ElicitRequestSchema, async (request) => {
229+ receivedSchema = request.params.requestedSchema;
230+ return { action: "accept" as const, content: {} };
231+ });
232+
233+ try {
234+ await client.callTool({
235+ name: "execute",
236+ arguments: { code: "approve" },
237+ });
238+ expect(receivedSchema).toEqual({
239+ type: "object",
240+ properties: {},
241+ });
242+ } finally {
243+ await close();
244+ }
245+ }),
246+ );
247+
248+ it.effect(
249+ "UrlElicitation is converted to a form with _url_hint property",
250+ () =>
251+ Effect.promise(async () => {
252+ let receivedMessage: string | undefined;
253+ let receivedSchema: Record<string, unknown> | undefined;
254+
255+ const engine = makeStubEngine({
256+ execute: async (_code, { onElicitation }) => {
257+ const response = await Effect.runPromise(
258+ onElicitation({
259+ toolId: "t" as any,
260+ args: {},
261+ request: new UrlElicitation({
262+ message: "Please authenticate",
263+ url: "https://example.com/oauth",
264+ elicitationId: "elic-1",
265+ }),
266+ }),
267+ );
268+ return { result: response.action };
269+ },
270+ });
271+
272+ const { client, close } = await connect(engine, {
273+ elicitation: { form: {} },
274+ });
275+
276+ client.setRequestHandler(ElicitRequestSchema, async (request) => {
277+ receivedMessage = request.params.message;
278+ receivedSchema = request.params.requestedSchema as Record<
279+ string,
280+ unknown
281+ >;
282+ return { action: "accept" as const, content: {} };
283+ });
284+
285+ try {
286+ await client.callTool({
287+ name: "execute",
288+ arguments: { code: "oauth" },
289+ });
290+ expect(receivedMessage).toBe("Please authenticate");
291+ expect(receivedSchema).toEqual({
292+ type: "object",
293+ properties: {
294+ _url_hint: {
295+ type: "string",
296+ description:
297+ "Please open this URL: https://example.com/oauth",
298+ default: "https://example.com/oauth",
299+ },
300+ },
301+ });
302+ } finally {
303+ await close();
304+ }
305+ }),
306+ );
307+
308+ it.effect(
309+ "engine error is surfaced as isError result",
310+ () =>
311+ Effect.promise(async () => {
312+ const engine = makeStubEngine({
313+ execute: async () => ({
314+ result: null,
315+ error: "something broke",
316+ logs: ["log1"],
317+ }),
318+ });
319+
320+ const { client, close } = await connect(engine, {
321+ elicitation: { form: {} },
322+ });
323+
324+ try {
325+ const result = await client.callTool({
326+ name: "execute",
327+ arguments: { code: "bad" },
328+ });
329+ expect(result.isError).toBe(true);
330+ const text = (result.content as any)[0].text;
331+ expect(text).toContain("something broke");
332+ } finally {
333+ await close();
334+ }
335+ }),
336+ );
337+
338+ it.effect(
339+ "resume tool is hidden when client supports elicitation",
340+ () =>
341+ Effect.promise(async () => {
342+ const engine = makeStubEngine({});
343+ const { client, close } = await connect(engine, {
344+ elicitation: { form: {} },
345+ });
346+
347+ try {
348+ const { tools } = await client.listTools();
349+ const names = tools.map((t) => t.name);
350+ expect(names).toContain("execute");
351+ expect(names).not.toContain("resume");
352+ } finally {
353+ await close();
354+ }
355+ }),
356+ );
357+});
358+
359+// ---------------------------------------------------------------------------
360+// Tests — client WITHOUT elicitation (pause/resume path)
361+// ---------------------------------------------------------------------------
362+
363+describe("MCP host server — client without elicitation (pause/resume)", () => {
364+ it.effect(
365+ "completed execution returns result directly",
366+ () =>
367+ Effect.promise(async () => {
368+ const engine = makeStubEngine({
369+ executeWithPause: async () => ({
370+ status: "completed",
371+ result: { result: "done" },
372+ }),
373+ });
374+
375+ const { client, close } = await connect(engine);
376+
377+ try {
378+ const result = await client.callTool({
379+ name: "execute",
380+ arguments: { code: "ok" },
381+ });
382+ expect(result.content).toEqual([{ type: "text", text: "done" }]);
383+ expect(result.isError).toBeFalsy();
384+ } finally {
385+ await close();
386+ }
387+ }),
388+ );
389+
390+ it.effect(
391+ "both execute and resume tools are visible",
392+ () =>
393+ Effect.promise(async () => {
394+ const engine = makeStubEngine({});
395+ const { client, close } = await connect(engine);
396+
397+ try {
398+ const { tools } = await client.listTools();
399+ const names = tools.map((t) => t.name);
400+ expect(names).toContain("execute");
401+ expect(names).toContain("resume");
402+ } finally {
403+ await close();
404+ }
405+ }),
406+ );
407+
408+ it.effect(
409+ "paused execution returns interaction metadata with executionId",
410+ () =>
411+ Effect.promise(async () => {
412+ const engine = makeStubEngine({
413+ executeWithPause: async (): Promise<ExecutionResult> => ({
414+ status: "paused",
415+ execution: {
416+ id: "exec_42",
417+ elicitationContext: {
418+ toolId: "t" as any,
419+ args: {},
420+ request: new FormElicitation({
421+ message: "Need approval",
422+ requestedSchema: {
423+ type: "object",
424+ properties: { ok: { type: "boolean" } },
425+ },
426+ }),
427+ },
428+ resolve: () => {},
429+ completion: new Promise(() => {}), // never resolves in this test
430+ },
431+ }),
432+ });
433+
434+ const { client, close } = await connect(engine);
435+
436+ try {
437+ const result = await client.callTool({
438+ name: "execute",
439+ arguments: { code: "pause-me" },
440+ });
441+ const text = (result.content as any)[0].text as string;
442+ expect(text).toContain("exec_42");
443+ expect(text).toContain("Need approval");
444+ expect(result.isError).toBeFalsy();
445+
446+ // structuredContent should contain the executionId
447+ expect((result as any).structuredContent?.executionId).toBe("exec_42");
448+ expect((result as any).structuredContent?.status).toBe(
449+ "waiting_for_interaction",
450+ );
451+ } finally {
452+ await close();
453+ }
454+ }),
455+ );
456+
457+ it.effect(
458+ "resume tool completes a paused execution",
459+ () =>
460+ Effect.promise(async () => {
461+ const engine = makeStubEngine({
462+ resume: async (executionId, response) => {
463+ if (executionId === "exec_1" && response.action === "accept") {
464+ return { result: "resumed-ok" };
465+ }
466+ return null;
467+ },
468+ });
469+
470+ const { client, close } = await connect(engine);
471+
472+ try {
473+ const result = await client.callTool({
474+ name: "resume",
475+ arguments: {
476+ executionId: "exec_1",
477+ action: "accept",
478+ content: "{}",
479+ },
480+ });
481+ expect(result.content).toEqual([
482+ { type: "text", text: "resumed-ok" },
483+ ]);
484+ expect(result.isError).toBeFalsy();
485+ } finally {
486+ await close();
487+ }
488+ }),
489+ );
490+
491+ it.effect(
492+ "resume tool passes parsed content to engine",
493+ () =>
494+ Effect.promise(async () => {
495+ let receivedContent: Record<string, unknown> | undefined;
496+
497+ const engine = makeStubEngine({
498+ resume: async (_id, response) => {
499+ receivedContent = response.content;
500+ return { result: "ok" };
501+ },
502+ });
503+
504+ const { client, close } = await connect(engine);
505+
506+ try {
507+ await client.callTool({
508+ name: "resume",
509+ arguments: {
510+ executionId: "exec_1",
511+ action: "accept",
512+ content: JSON.stringify({ approved: true, name: "test" }),
513+ },
514+ });
515+ expect(receivedContent).toEqual({ approved: true, name: "test" });
516+ } finally {
517+ await close();
518+ }
519+ }),
520+ );
521+
522+ it.effect(
523+ "resume with empty content passes undefined",
524+ () =>
525+ Effect.promise(async () => {
526+ let receivedContent: Record<string, unknown> | undefined = { marker: true };
527+
528+ const engine = makeStubEngine({
529+ resume: async (_id, response) => {
530+ receivedContent = response.content;
531+ return { result: "ok" };
532+ },
533+ });
534+
535+ const { client, close } = await connect(engine);
536+
537+ try {
538+ await client.callTool({
539+ name: "resume",
540+ arguments: {
541+ executionId: "exec_1",
542+ action: "accept",
543+ content: "{}",
544+ },
545+ });
546+ expect(receivedContent).toBeUndefined();
547+ } finally {
548+ await close();
549+ }
550+ }),
551+ );
552+
553+ it.effect(
554+ "resume with unknown executionId returns error",
555+ () =>
556+ Effect.promise(async () => {
557+ const engine = makeStubEngine({
558+ resume: async () => null,
559+ });
560+
561+ const { client, close } = await connect(engine);
562+
563+ try {
564+ const result = await client.callTool({
565+ name: "resume",
566+ arguments: {
567+ executionId: "does-not-exist",
568+ action: "accept",
569+ content: "{}",
570+ },
571+ });
572+ expect(result.isError).toBe(true);
573+ const text = (result.content as any)[0].text;
574+ expect(text).toContain("does-not-exist");
575+ } finally {
576+ await close();
577+ }
578+ }),
579+ );
580+
581+ it.effect(
582+ "paused UrlElicitation includes url and kind in structured output",
583+ () =>
584+ Effect.promise(async () => {
585+ const engine = makeStubEngine({
586+ executeWithPause: async (): Promise<ExecutionResult> => ({
587+ status: "paused",
588+ execution: {
589+ id: "exec_99",
590+ elicitationContext: {
591+ toolId: "t" as any,
592+ args: {},
593+ request: new UrlElicitation({
594+ message: "Please authenticate",
595+ url: "https://auth.example.com/callback",
596+ elicitationId: "elic-url-1",
597+ }),
598+ },
599+ resolve: () => {},
600+ completion: new Promise(() => {}),
601+ },
602+ }),
603+ });
604+
605+ const { client, close } = await connect(engine);
606+
607+ try {
608+ const result = await client.callTool({
609+ name: "execute",
610+ arguments: { code: "oauth" },
611+ });
612+ const text = (result.content as any)[0].text as string;
613+ expect(text).toContain("https://auth.example.com/callback");
614+ expect(text).toContain("exec_99");
615+
616+ const structured = (result as any).structuredContent;
617+ expect(structured?.interaction?.kind).toBe("url");
618+ expect(structured?.interaction?.url).toBe(
619+ "https://auth.example.com/callback",
620+ );
621+ } finally {
622+ await close();
623+ }
624+ }),
625+ );
626+});
627+
628+// ---------------------------------------------------------------------------
629+// Tests — elicitation error handling
630+// ---------------------------------------------------------------------------
631+
632+describe("MCP host server — elicitation error handling", () => {
633+ it.effect(
634+ "elicitInput failure falls back to cancel",
635+ () =>
636+ Effect.promise(async () => {
637+ const engine = makeStubEngine({
638+ execute: async (_code, { onElicitation }) => {
639+ const response = await Effect.runPromise(
640+ onElicitation({
641+ toolId: "t" as any,
642+ args: {},
643+ request: new FormElicitation({
644+ message: "will fail",
645+ requestedSchema: {
646+ type: "object",
647+ properties: { x: { type: "string" } },
648+ },
649+ }),
650+ }),
651+ );
652+ return { result: `fallback:${response.action}` };
653+ },
654+ });
655+
656+ const { client, close } = await connect(engine, {
657+ elicitation: { form: {} },
658+ });
659+
660+ // Don't register any handler — the client will reject with an error
661+ // when it receives an elicitation request it can't handle.
662+ // But the MCP SDK might auto-handle... let's explicitly throw.
663+ client.setRequestHandler(ElicitRequestSchema, async () => {
664+ throw new Error("client cannot handle this");
665+ });
666+
667+ try {
668+ const result = await client.callTool({
669+ name: "execute",
670+ arguments: { code: "fail" },
671+ });
672+ // The server catches the error and returns cancel
673+ expect(result.content).toEqual([
674+ { type: "text", text: "fallback:cancel" },
675+ ]);
676+ } finally {
677+ await close();
678+ }
679+ }),
680+ );
681+});
682+
683+// ---------------------------------------------------------------------------
684+// Tests — parseJsonContent edge cases
685+// ---------------------------------------------------------------------------
686+
687+describe("MCP host server — resume content parsing", () => {
688+ it.effect(
689+ "array JSON is rejected (not passed as content)",
690+ () =>
691+ Effect.promise(async () => {
692+ let receivedContent: Record<string, unknown> | undefined = { marker: true };
693+
694+ const engine = makeStubEngine({
695+ resume: async (_id, response) => {
696+ receivedContent = response.content;
697+ return { result: "ok" };
698+ },
699+ });
700+
701+ const { client, close } = await connect(engine);
702+
703+ try {
704+ await client.callTool({
705+ name: "resume",
706+ arguments: {
707+ executionId: "exec_1",
708+ action: "accept",
709+ content: "[1,2,3]",
710+ },
711+ });
712+ // Array should be rejected — engine receives undefined
713+ expect(receivedContent).toBeUndefined();
714+ } finally {
715+ await close();
716+ }
717+ }),
718+ );
719+
720+ it.effect(
721+ "invalid JSON is handled gracefully (not thrown)",
722+ () =>
723+ Effect.promise(async () => {
724+ let receivedContent: Record<string, unknown> | undefined = { marker: true };
725+
726+ const engine = makeStubEngine({
727+ resume: async (_id, response) => {
728+ receivedContent = response.content;
729+ return { result: "ok" };
730+ },
731+ });
732+
733+ const { client, close } = await connect(engine);
734+
735+ try {
736+ const result = await client.callTool({
737+ name: "resume",
738+ arguments: {
739+ executionId: "exec_1",
740+ action: "accept",
741+ content: "not-valid-json",
742+ },
743+ });
744+ // Should not crash — invalid JSON treated as undefined content
745+ expect(receivedContent).toBeUndefined();
746+ expect(result.isError).toBeFalsy();
747+ } finally {
748+ await close();
749+ }
750+ }),
751+ );
752+});
753+
754+// ---------------------------------------------------------------------------
755+// Tests — multiple elicitations in a single execution
756+// ---------------------------------------------------------------------------
757+
758+describe("MCP host server — multiple elicitations", () => {
759+ it.effect(
760+ "engine can elicit multiple times during a single execute call",
761+ () =>
762+ Effect.promise(async () => {
763+ const engine = makeStubEngine({
764+ execute: async (_code, { onElicitation }) => {
765+ // First elicitation — ask for name
766+ const r1 = await Effect.runPromise(
767+ onElicitation({
768+ toolId: "t" as any,
769+ args: {},
770+ request: new FormElicitation({
771+ message: "What is your name?",
772+ requestedSchema: {
773+ type: "object",
774+ properties: { name: { type: "string" } },
775+ },
776+ }),
777+ }),
778+ );
779+
780+ // Second elicitation — ask for confirmation
781+ const r2 = await Effect.runPromise(
782+ onElicitation({
783+ toolId: "t" as any,
784+ args: {},
785+ request: new FormElicitation({
786+ message: `Confirm: ${r1.content?.name}?`,
787+ requestedSchema: {
788+ type: "object",
789+ properties: { confirmed: { type: "boolean" } },
790+ },
791+ }),
792+ }),
793+ );
794+
795+ return {
796+ result: `name=${r1.content?.name},confirmed=${r2.content?.confirmed}`,
797+ };
798+ },
799+ });
800+
801+ const { client, close } = await connect(engine, {
802+ elicitation: { form: {} },
803+ });
804+
805+ let callCount = 0;
806+ client.setRequestHandler(ElicitRequestSchema, async (request) => {
807+ callCount++;
808+ if (callCount === 1) {
809+ return { action: "accept" as const, content: { name: "Alice" } };
810+ }
811+ return { action: "accept" as const, content: { confirmed: true } };
812+ });
813+
814+ try {
815+ const result = await client.callTool({
816+ name: "execute",
817+ arguments: { code: "multi" },
818+ });
819+ expect(result.content).toEqual([
820+ { type: "text", text: "name=Alice,confirmed=true" },
821+ ]);
822+ expect(callCount).toBe(2);
823+ } finally {
824+ await close();
825+ }
826+ }),
827+ );
828+});
packages/hosts/mcp/src/server.ts+7−2
@@ -148,8 +148,13 @@ export const createExecutorMcpServer = async ( · 147 unmodified lines
148148
149149 const parseJsonContent = (raw: string): Record<string, unknown> | undefined => {
150150 if (raw === "{}") return undefined;
151− const parsed: unknown = JSON.parse(raw);
152− return typeof parsed === "object" && parsed !== null
151+ let parsed: unknown;
152+ try {
153+ parsed = JSON.parse(raw);
154+ } catch {
155+ return undefined;
156+ }
157+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
153158 ? (parsed as Record<string, unknown>)
154159 : undefined;
155160 };
packages/hosts/mcp/vitest.config.ts+7−0
@@ -0,0 +1,7 @@
1+import { defineConfig } from "vitest/config";
2+
3+export default defineConfig({
4+ test: {
5+ include: ["src/**/*.test.ts"],
6+ },
7+});

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t