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
5fix elicitation
.mcp.json+3−1
@@ -6,7 +6,9 @@ · 5 unmodified lines
6 "run",6 "run",
7 "/Users/rhyssullivan/src/v4-executor/apps/cli/src/main.ts",7 "/Users/rhyssullivan/src/v4-executor/apps/cli/src/main.ts",
8 "mcp"8 "mcp"
9 ]9 ],
10 "type": "http",
11 "url": "http://localhost:5173/mcp"
10 }12 }
11 }13 }
12}14}
bun.lock+3−0
@@ -204,8 +204,11 @@ · 203 unmodified lines
204 "zod": "^4.3.0",204 "zod": "^4.3.0",
205 },205 },
206 "devDependencies": {206 "devDependencies": {
207 "@effect/vitest": "catalog:",
208 "@executor/codemode-core": "workspace:*",
207 "@types/node": "catalog:",209 "@types/node": "catalog:",
208 "bun-types": "catalog:",210 "bun-types": "catalog:",
211 "vitest": "catalog:",
209 },212 },
210 },213 },
211 "packages/kernel/core": {214 "packages/kernel/core": {
packages/core/execution/src/engine.ts+9−20
@@ -295,7 +295,11 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE · 294 unmodified lines
295 },295 },
296 296
297 executeWithPause: async (code) => {297 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 });
299 303
300 const elicitationHandler: ElicitationHandler = (ctx: ElicitationContext) =>304 const elicitationHandler: ElicitationHandler = (ctx: ElicitationContext) =>
301 Effect.async<typeof ElicitationResponse.Type>((resume) => {305 Effect.async<typeof ElicitationResponse.Type>((resume) => {
@@ -306,32 +310,17 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE · 4 unmodified lines
306 resolve: (response) => resume(Effect.succeed(response)),310 resolve: (response) => resume(Effect.succeed(response)),
307 completion: undefined as unknown as Promise<ExecuteResult>,311 completion: undefined as unknown as Promise<ExecuteResult>,
308 };312 };
309 pausedResolve = paused;
310 pausedExecutions.set(id, paused);313 pausedExecutions.set(id, paused);
314 signalPause!(paused);
311 });315 });
312 316
313 const invoker = makeFullInvoker(executor, { onElicitation: elicitationHandler });317 const invoker = makeFullInvoker(executor, { onElicitation: elicitationHandler });
314 const completionPromise = runEffect(codeExecutor.execute(code, invoker));318 const completionPromise = runEffect(codeExecutor.execute(code, invoker));
315 319
316 if (pausedResolve) {
317 (pausedResolve as { completion: Promise<ExecuteResult> }).completion = completionPromise;
318 }
319
320 // Race: either the execution completes, or it pauses for elicitation.320 // Race: either the execution completes, or it pauses for elicitation.
321 // Poll briefly to detect pauses that fire asynchronously.
322 const result = await Promise.race([321 const result = await Promise.race([
323 completionPromise.then((r) => ({ kind: "completed" as const, result: r })),322 completionPromise.then((r) => ({ kind: "completed" as const, result: r })),
324 new Promise<{ kind: "poll" }>((resolve) => {323 pausePromise.then((p) => ({ kind: "paused" as const, execution: p })),
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 }),
335 ]);324 ]);
336 325
337 if (result.kind === "completed") {326 if (result.kind === "completed") {
@@ -339,8 +328,8 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE · 1 unmodified line
339 }328 }
340 329
341 // Execution paused — attach the completion promise and return330 // Execution paused — attach the completion promise and return
342 (pausedResolve as unknown as { completion: Promise<ExecuteResult> }).completion = completionPromise;331 (result.execution as { completion: Promise<ExecuteResult> }).completion = completionPromise;
343 return { status: "paused", execution: pausedResolve! };332 return { status: "paused", execution: result.execution };
344 },333 },
345 334
346 resume: async (executionId, response) => {335 resume: async (executionId, response) => {
packages/hosts/mcp/package.json+7−2
@@ -7,7 +7,9 @@ · 6 unmodified lines
7 ".": "./src/index.ts"7 ".": "./src/index.ts"
8 },8 },
9 "scripts": {9 "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"
11 },13 },
12 "dependencies": {14 "dependencies": {
13 "@executor/sdk": "workspace:*",15 "@executor/sdk": "workspace:*",
@@ -17,7 +19,10 @@ · 3 unmodified lines
17 "zod": "^4.3.0"19 "zod": "^4.3.0"
18 },20 },
19 "devDependencies": {21 "devDependencies": {
22 "@effect/vitest": "catalog:",
23 "@executor/codemode-core": "workspace:*",
20 "@types/node": "catalog:",24 "@types/node": "catalog:",
21 "bun-types": "catalog:"25 "bun-types": "catalog:",
26 "vitest": "catalog:"
22 }27 }
23}28}
packages/hosts/mcp/src/server.test.ts+828−0
@@ -0,0 +1,828 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
5import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6
7import {
8 FormElicitation,
9 UrlElicitation,
10 ElicitationResponse,
11 type ElicitationHandler,
12 type ElicitationContext,
13} from "@executor/sdk";
14import type { ExecutionEngine, ExecutionResult, PausedExecution } from "@executor/execution";
15import type { ExecuteResult } from "@executor/codemode-core";
16
17import { 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 */
27const 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
44type 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 */
54const 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
82describe("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
363describe("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
632describe("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
687describe("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
758describe("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
148 148
149 const parseJsonContent = (raw: string): Record<string, unknown> | undefined => {149 const parseJsonContent = (raw: string): Record<string, unknown> | undefined => {
150 if (raw === "{}") return undefined;150 if (raw === "{}") return undefined;
151 const parsed: unknown = JSON.parse(raw);151 let parsed: unknown;
152 return typeof parsed === "object" && parsed !== null152 try {
153 parsed = JSON.parse(raw);
154 } catch {
155 return undefined;
156 }
157 return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
153 ? (parsed as Record<string, unknown>)158 ? (parsed as Record<string, unknown>)
154 : undefined;159 : undefined;
155 };160 };
packages/hosts/mcp/vitest.config.ts+7−0
@@ -0,0 +1,7 @@
1import { defineConfig } from "vitest/config";
2
3export 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