History

relesase

Rhys Sullivan committed 7 months ago

Commit ee347b91 parent 376b3a45 files · +647 −725Browse files

Unified · Split

5 files
  1. .changeset/four-eels-itch.md +5 −0
  2. .changeset/pre.json +1 −1
  3. packages/core/api/src/executions/api.ts +1 −2
  4. packages/hosts/mcp/src/server.test.ts +634 −717
  5. packages/plugins/openapi/src/sdk/real-specs.test.ts +6 −5
.changeset/four-eels-itch.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"executor": patch
3+---
4+
5+Release v1.4
.changeset/pre.json+1−1
@@ -1,5 +1,5 @@
11 {
2− "mode": "pre",
2+ "mode": "exit",
33 "tag": "beta",
44 "initialVersions": {
55 "executor": "1.4.0-beta.0",
packages/core/api/src/executions/api.ts+1−2
@@ -35,8 +35,7 @@ const ResumeResponse = Schema.Struct({ · 34 unmodified lines
3535 isError: Schema.Boolean,
3636 });
3737
38−const ExecutionNotFoundError = Schema.Struct({
39− _tag: Schema.Literal("ExecutionNotFoundError"),
38+const ExecutionNotFoundError = Schema.TaggedStruct("ExecutionNotFoundError", {
4039 executionId: Schema.String,
4140 }).annotations(HttpApiSchema.annotations({ status: 404 }));
4241
packages/hosts/mcp/src/server.test.ts+634−717
@@ -1,18 +1,12 @@
1−import { describe, expect, it } from "@effect/vitest";
1+import { describe, expect, it } from "vitest";
22 import { Effect } from "effect";
33 import { Client } from "@modelcontextprotocol/sdk/client/index.js";
44 import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
55 import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6+import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
67
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";
8+import { FormElicitation, UrlElicitation } from "@executor/sdk";
9+import type { ExecutionEngine, ExecutionResult } from "@executor/execution";
1610
1711 import { createExecutorMcpServer } from "./server";
1812
@@ -53,7 +47,7 @@ type TestHarness = { · 34 unmodified lines
5347 */
5448 const connect = async (
5549 engine: ExecutionEngine,
56− clientCapabilities: { elicitation?: { form?: object } } = {},
50+ clientCapabilities: ClientCapabilities = {},
5751 ): Promise<TestHarness> => {
5852 const mcpServer = await createExecutorMcpServer({ engine });
5953 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
@@ -80,280 +74,255 @@ const connect = async ( · 20 unmodified lines
8074 // ---------------------------------------------------------------------------
8175
8276 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− }
77+ it("execute tool calls engine.execute and returns result", async () => {
78+ const engine = makeStubEngine({
79+ execute: async (code) => ({
80+ result: `ran: ${code}`,
19981 }),
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",
82+ });
83+
84+ const { client, close } = await connect(engine, {
85+ elicitation: { form: {} },
86+ });
87+
88+ try {
89+ const result = await client.callTool({ name: "execute", arguments: { code: "1+1" } });
90+ expect(result.content).toEqual([{ type: "text", text: "ran: 1+1" }]);
91+ expect(result.isError).toBeFalsy();
92+ } finally {
93+ await close();
94+ }
95+ });
96+
97+ it("form elicitation is bridged from engine to MCP client and back", async () => {
98+ const engine = makeStubEngine({
99+ execute: async (code, { onElicitation }) => {
100+ const response = await Effect.runPromise(
101+ onElicitation({
102+ toolId: "test-tool" as any,
103+ args: { code },
104+ request: new FormElicitation({
105+ message: "Approve this action?",
106+ requestedSchema: {
107+ type: "object",
108+ properties: {
109+ approved: { type: "boolean" },
110+ },
299111 },
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"],
112+ }),
317113 }),
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− }
114+ );
115+ return {
116+ result:
117+ response.action === "accept" && response.content?.approved
118+ ? "approved"
119+ : "denied",
120+ };
121+ },
122+ });
123+
124+ const { client, close } = await connect(engine, {
125+ elicitation: { form: {} },
126+ });
127+
128+ // Register a client-side handler that auto-accepts
129+ client.setRequestHandler(ElicitRequestSchema, async () => ({
130+ action: "accept" as const,
131+ content: { approved: true },
132+ }));
133+
134+ try {
135+ const result = await client.callTool({
136+ name: "execute",
137+ arguments: { code: "do-it" },
138+ });
139+ expect(result.content).toEqual([{ type: "text", text: "approved" }]);
140+ } finally {
141+ await close();
142+ }
143+ });
144+
145+ it("form elicitation declined by client → engine sees decline", async () => {
146+ const engine = makeStubEngine({
147+ execute: async (code, { onElicitation }) => {
148+ const response = await Effect.runPromise(
149+ onElicitation({
150+ toolId: "t" as any,
151+ args: {},
152+ request: new FormElicitation({
153+ message: "Accept?",
154+ requestedSchema: {},
155+ }),
156+ }),
157+ );
158+ return { result: `action:${response.action}` };
159+ },
160+ });
161+
162+ const { client, close } = await connect(engine, {
163+ elicitation: { form: {} },
164+ });
165+
166+ client.setRequestHandler(ElicitRequestSchema, async () => ({
167+ action: "decline" as const,
168+ content: {},
169+ }));
170+
171+ try {
172+ const result = await client.callTool({
173+ name: "execute",
174+ arguments: { code: "x" },
175+ });
176+ expect(result.content).toEqual([
177+ { type: "text", text: "action:decline" },
178+ ]);
179+ } finally {
180+ await close();
181+ }
182+ });
183+
184+ it("empty form schema gets wrapped with minimal valid schema", async () => {
185+ let receivedSchema: unknown;
186+
187+ const engine = makeStubEngine({
188+ execute: async (_code, { onElicitation }) => {
189+ const response = await Effect.runPromise(
190+ onElicitation({
191+ toolId: "t" as any,
192+ args: {},
193+ request: new FormElicitation({
194+ message: "Just approve",
195+ requestedSchema: {}, // empty — approval only
196+ }),
197+ }),
198+ );
199+ return { result: response.action };
200+ },
201+ });
202+
203+ const { client, close } = await connect(engine, {
204+ elicitation: { form: {} },
205+ });
206+
207+ client.setRequestHandler(ElicitRequestSchema, async (request) => {
208+ const params = request.params;
209+ if ("requestedSchema" in params) {
210+ receivedSchema = params.requestedSchema;
211+ }
212+ return { action: "accept" as const, content: {} };
213+ });
214+
215+ try {
216+ await client.callTool({
217+ name: "execute",
218+ arguments: { code: "approve" },
219+ });
220+ expect(receivedSchema).toEqual({
221+ type: "object",
222+ properties: {},
223+ });
224+ } finally {
225+ await close();
226+ }
227+ });
228+
229+ it("UrlElicitation is converted to a form with _url_hint property", async () => {
230+ let receivedMessage: string | undefined;
231+ let receivedSchema: Record<string, unknown> | undefined;
232+
233+ const engine = makeStubEngine({
234+ execute: async (_code, { onElicitation }) => {
235+ const response = await Effect.runPromise(
236+ onElicitation({
237+ toolId: "t" as any,
238+ args: {},
239+ request: new UrlElicitation({
240+ message: "Please authenticate",
241+ url: "https://example.com/oauth",
242+ elicitationId: "elic-1",
243+ }),
244+ }),
245+ );
246+ return { result: response.action };
247+ },
248+ });
249+
250+ const { client, close } = await connect(engine, {
251+ elicitation: { form: {} },
252+ });
253+
254+ client.setRequestHandler(ElicitRequestSchema, async (request) => {
255+ receivedMessage = request.params.message;
256+ const params = request.params;
257+ if ("requestedSchema" in params) {
258+ receivedSchema = params.requestedSchema as Record<string, unknown>;
259+ }
260+ return { action: "accept" as const, content: {} };
261+ });
262+
263+ try {
264+ await client.callTool({
265+ name: "execute",
266+ arguments: { code: "oauth" },
267+ });
268+ expect(receivedMessage).toBe("Please authenticate");
269+ expect(receivedSchema).toEqual({
270+ type: "object",
271+ properties: {
272+ _url_hint: {
273+ type: "string",
274+ description:
275+ "Please open this URL: https://example.com/oauth",
276+ default: "https://example.com/oauth",
277+ },
278+ },
279+ });
280+ } finally {
281+ await close();
282+ }
283+ });
284+
285+ it("engine error is surfaced as isError result", async () => {
286+ const engine = makeStubEngine({
287+ execute: async () => ({
288+ result: null,
289+ error: "something broke",
290+ logs: ["log1"],
355291 }),
356− );
292+ });
293+
294+ const { client, close } = await connect(engine, {
295+ elicitation: { form: {} },
296+ });
297+
298+ try {
299+ const result = await client.callTool({
300+ name: "execute",
301+ arguments: { code: "bad" },
302+ });
303+ expect(result.isError).toBe(true);
304+ const text = (result.content as Array<{ type: string; text: string }>)[0].text;
305+ expect(text).toContain("something broke");
306+ } finally {
307+ await close();
308+ }
309+ });
310+
311+ it("resume tool is hidden when client supports elicitation", async () => {
312+ const engine = makeStubEngine({});
313+ const { client, close } = await connect(engine, {
314+ elicitation: { form: {} },
315+ });
316+
317+ try {
318+ const { tools } = await client.listTools();
319+ const names = tools.map((t) => t.name);
320+ expect(names).toContain("execute");
321+ expect(names).not.toContain("resume");
322+ } finally {
323+ await close();
324+ }
325+ });
357326 });
358327
359328 // ---------------------------------------------------------------------------
@@ -361,268 +330,234 @@ describe("MCP host server — client with elicitation", () => { · 1 unmodified line
361330 // ---------------------------------------------------------------------------
362331
363332 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− }
333+ it("completed execution returns result directly", async () => {
334+ const engine = makeStubEngine({
335+ executeWithPause: async () => ({
336+ status: "completed",
337+ result: { result: "done" },
405338 }),
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− }),
339+ });
340+
341+ const { client, close } = await connect(engine);
342+
343+ try {
344+ const result = await client.callTool({
345+ name: "execute",
346+ arguments: { code: "ok" },
347+ });
348+ expect(result.content).toEqual([{ type: "text", text: "done" }]);
349+ expect(result.isError).toBeFalsy();
350+ } finally {
351+ await close();
352+ }
353+ });
354+
355+ it("both execute and resume tools are visible", async () => {
356+ const engine = makeStubEngine({});
357+ const { client, close } = await connect(engine);
358+
359+ try {
360+ const { tools } = await client.listTools();
361+ const names = tools.map((t) => t.name);
362+ expect(names).toContain("execute");
363+ expect(names).toContain("resume");
364+ } finally {
365+ await close();
366+ }
367+ });
368+
369+ it("paused execution returns interaction metadata with executionId", async () => {
370+ const engine = makeStubEngine({
371+ executeWithPause: async (): Promise<ExecutionResult> => ({
372+ status: "paused",
373+ execution: {
374+ id: "exec_42",
375+ elicitationContext: {
376+ toolId: "t" as any,
377+ args: {},
378+ request: new FormElicitation({
379+ message: "Need approval",
380+ requestedSchema: {
381+ type: "object",
382+ properties: { ok: { type: "boolean" } },
427383 },
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;
384+ }),
467385 },
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− }
386+ resolve: () => {},
387+ completion: new Promise(() => {}), // never resolves in this test
388+ },
488389 }),
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();
390+ });
391+
392+ const { client, close } = await connect(engine);
393+
394+ try {
395+ const result = await client.callTool({
396+ name: "execute",
397+ arguments: { code: "pause-me" },
398+ });
399+ const text = (result.content as Array<{ type: string; text: string }>)[0].text;
400+ expect(text).toContain("exec_42");
401+ expect(text).toContain("Need approval");
402+ expect(result.isError).toBeFalsy();
403+
404+ // structuredContent should contain the executionId
405+ const structured = result.structuredContent as Record<string, unknown>;
406+ expect(structured?.executionId).toBe("exec_42");
407+ expect(structured?.status).toBe("waiting_for_interaction");
408+ } finally {
409+ await close();
410+ }
411+ });
412+
413+ it("resume tool completes a paused execution", async () => {
414+ const engine = makeStubEngine({
415+ resume: async (executionId, response) => {
416+ if (executionId === "exec_1" && response.action === "accept") {
417+ return { result: "resumed-ok" };
518418 }
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" };
419+ return null;
420+ },
421+ });
422+
423+ const { client, close } = await connect(engine);
424+
425+ try {
426+ const result = await client.callTool({
427+ name: "resume",
428+ arguments: {
429+ executionId: "exec_1",
430+ action: "accept",
431+ content: "{}",
432+ },
433+ });
434+ expect(result.content).toEqual([
435+ { type: "text", text: "resumed-ok" },
436+ ]);
437+ expect(result.isError).toBeFalsy();
438+ } finally {
439+ await close();
440+ }
441+ });
442+
443+ it("resume tool passes parsed content to engine", async () => {
444+ let receivedContent: Record<string, unknown> | undefined;
445+
446+ const engine = makeStubEngine({
447+ resume: async (_id, response) => {
448+ receivedContent = response.content;
449+ return { result: "ok" };
450+ },
451+ });
452+
453+ const { client, close } = await connect(engine);
454+
455+ try {
456+ await client.callTool({
457+ name: "resume",
458+ arguments: {
459+ executionId: "exec_1",
460+ action: "accept",
461+ content: JSON.stringify({ approved: true, name: "test" }),
462+ },
463+ });
464+ expect(receivedContent).toEqual({ approved: true, name: "test" });
465+ } finally {
466+ await close();
467+ }
468+ });
469+
470+ it("resume with empty content passes undefined", async () => {
471+ let receivedContent: Record<string, unknown> | undefined = { marker: true };
472+
473+ const engine = makeStubEngine({
474+ resume: async (_id, response) => {
475+ receivedContent = response.content;
476+ return { result: "ok" };
477+ },
478+ });
479+
480+ const { client, close } = await connect(engine);
481+
482+ try {
483+ await client.callTool({
484+ name: "resume",
485+ arguments: {
486+ executionId: "exec_1",
487+ action: "accept",
488+ content: "{}",
489+ },
490+ });
491+ expect(receivedContent).toBeUndefined();
492+ } finally {
493+ await close();
494+ }
495+ });
496+
497+ it("resume with unknown executionId returns error", async () => {
498+ const engine = makeStubEngine({
499+ resume: async () => null,
500+ });
501+
502+ const { client, close } = await connect(engine);
503+
504+ try {
505+ const result = await client.callTool({
506+ name: "resume",
507+ arguments: {
508+ executionId: "does-not-exist",
509+ action: "accept",
510+ content: "{}",
511+ },
512+ });
513+ expect(result.isError).toBe(true);
514+ const text = (result.content as Array<{ type: string; text: string }>)[0].text;
515+ expect(text).toContain("does-not-exist");
516+ } finally {
517+ await close();
518+ }
519+ });
520+
521+ it("paused UrlElicitation includes url and kind in structured output", async () => {
522+ const engine = makeStubEngine({
523+ executeWithPause: async (): Promise<ExecutionResult> => ({
524+ status: "paused",
525+ execution: {
526+ id: "exec_99",
527+ elicitationContext: {
528+ toolId: "t" as any,
529+ args: {},
530+ request: new UrlElicitation({
531+ message: "Please authenticate",
532+ url: "https://auth.example.com/callback",
533+ elicitationId: "elic-url-1",
534+ }),
532535 },
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− }
536+ resolve: () => {},
537+ completion: new Promise(() => {}),
538+ },
550539 }),
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− );
540+ });
541+
542+ const { client, close } = await connect(engine);
543+
544+ try {
545+ const result = await client.callTool({
546+ name: "execute",
547+ arguments: { code: "oauth" },
548+ });
549+ const text = (result.content as Array<{ type: string; text: string }>)[0].text;
550+ expect(text).toContain("https://auth.example.com/callback");
551+ expect(text).toContain("exec_99");
552+
553+ const structured = result.structuredContent as Record<string, unknown>;
554+ const interaction = structured?.interaction as Record<string, unknown>;
555+ expect(interaction?.kind).toBe("url");
556+ expect(interaction?.url).toBe("https://auth.example.com/callback");
557+ } finally {
558+ await close();
559+ }
560+ });
626561 });
627562
628563 // ---------------------------------------------------------------------------
@@ -630,54 +565,48 @@ describe("MCP host server — client without elicitation (pause/resume)", () => · 1 unmodified line
630565 // ---------------------------------------------------------------------------
631566
632567 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− );
568+ it("elicitInput failure falls back to cancel", async () => {
569+ const engine = makeStubEngine({
570+ execute: async (_code, { onElicitation }) => {
571+ const response = await Effect.runPromise(
572+ onElicitation({
573+ toolId: "t" as any,
574+ args: {},
575+ request: new FormElicitation({
576+ message: "will fail",
577+ requestedSchema: {
578+ type: "object",
579+ properties: { x: { type: "string" } },
580+ },
581+ }),
582+ }),
583+ );
584+ return { result: `fallback:${response.action}` };
585+ },
586+ });
587+
588+ const { client, close } = await connect(engine, {
589+ elicitation: { form: {} },
590+ });
591+
592+ // Client throws when it receives the elicitation — server should catch
593+ client.setRequestHandler(ElicitRequestSchema, async () => {
594+ throw new Error("client cannot handle this");
595+ });
596+
597+ try {
598+ const result = await client.callTool({
599+ name: "execute",
600+ arguments: { code: "fail" },
601+ });
602+ // The server catches the error and returns cancel
603+ expect(result.content).toEqual([
604+ { type: "text", text: "fallback:cancel" },
605+ ]);
606+ } finally {
607+ await close();
608+ }
609+ });
681610 });
682611
683612 // ---------------------------------------------------------------------------
@@ -685,70 +614,62 @@ describe("MCP host server — elicitation error handling", () => { · 1 unmodified line
685614 // ---------------------------------------------------------------------------
686615
687616 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− );
617+ it("array JSON is rejected (not passed as content)", async () => {
618+ let receivedContent: Record<string, unknown> | undefined = { marker: true };
619+
620+ const engine = makeStubEngine({
621+ resume: async (_id, response) => {
622+ receivedContent = response.content;
623+ return { result: "ok" };
624+ },
625+ });
626+
627+ const { client, close } = await connect(engine);
628+
629+ try {
630+ await client.callTool({
631+ name: "resume",
632+ arguments: {
633+ executionId: "exec_1",
634+ action: "accept",
635+ content: "[1,2,3]",
636+ },
637+ });
638+ // Array should be rejected — engine receives undefined
639+ expect(receivedContent).toBeUndefined();
640+ } finally {
641+ await close();
642+ }
643+ });
644+
645+ it("invalid JSON is handled gracefully (not thrown)", async () => {
646+ let receivedContent: Record<string, unknown> | undefined = { marker: true };
647+
648+ const engine = makeStubEngine({
649+ resume: async (_id, response) => {
650+ receivedContent = response.content;
651+ return { result: "ok" };
652+ },
653+ });
654+
655+ const { client, close } = await connect(engine);
656+
657+ try {
658+ const result = await client.callTool({
659+ name: "resume",
660+ arguments: {
661+ executionId: "exec_1",
662+ action: "accept",
663+ content: "not-valid-json",
664+ },
665+ });
666+ // Should not crash — invalid JSON treated as undefined content
667+ expect(receivedContent).toBeUndefined();
668+ expect(result.isError).toBeFalsy();
669+ } finally {
670+ await close();
671+ }
672+ });
752673 });
753674
754675 // ---------------------------------------------------------------------------
@@ -756,73 +677,69 @@ describe("MCP host server — resume content parsing", () => { · 1 unmodified line
756677 // ---------------------------------------------------------------------------
757678
758679 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− );
680+ it("engine can elicit multiple times during a single execute call", async () => {
681+ const engine = makeStubEngine({
682+ execute: async (_code, { onElicitation }) => {
683+ // First elicitation — ask for name
684+ const r1 = await Effect.runPromise(
685+ onElicitation({
686+ toolId: "t" as any,
687+ args: {},
688+ request: new FormElicitation({
689+ message: "What is your name?",
690+ requestedSchema: {
691+ type: "object",
692+ properties: { name: { type: "string" } },
693+ },
694+ }),
695+ }),
696+ );
697+
698+ // Second elicitation — ask for confirmation
699+ const r2 = await Effect.runPromise(
700+ onElicitation({
701+ toolId: "t" as any,
702+ args: {},
703+ request: new FormElicitation({
704+ message: `Confirm: ${r1.content?.name}?`,
705+ requestedSchema: {
706+ type: "object",
707+ properties: { confirmed: { type: "boolean" } },
708+ },
709+ }),
710+ }),
711+ );
712+
713+ return {
714+ result: `name=${r1.content?.name},confirmed=${r2.content?.confirmed}`,
715+ };
716+ },
717+ });
718+
719+ const { client, close } = await connect(engine, {
720+ elicitation: { form: {} },
721+ });
722+
723+ let callCount = 0;
724+ client.setRequestHandler(ElicitRequestSchema, async () => {
725+ callCount++;
726+ if (callCount === 1) {
727+ return { action: "accept" as const, content: { name: "Alice" } };
728+ }
729+ return { action: "accept" as const, content: { confirmed: true } };
730+ });
731+
732+ try {
733+ const result = await client.callTool({
734+ name: "execute",
735+ arguments: { code: "multi" },
736+ });
737+ expect(result.content).toEqual([
738+ { type: "text", text: "name=Alice,confirmed=true" },
739+ ]);
740+ expect(callCount).toBe(2);
741+ } finally {
742+ await close();
743+ }
744+ });
828745 });
packages/plugins/openapi/src/sdk/real-specs.test.ts+6−5
@@ -150,13 +150,14 @@ describe("Real specs: Cloudflare API", () => { · 149 unmodified lines
150150 expect(tools.length).toBeGreaterThan(0);
151151
152152 const schema = yield* executor.tools.schema(tools[0]!.id);
153− const output = schema.outputSchema as Record<string, unknown>;
154153
155− expect(output["$ref"]).toBeTypeOf("string");
156− expect((output["$ref"] as string).startsWith("#/$defs/")).toBe(true);
154+ // outputTypeScript should reference a named type from definitions
155+ expect(schema.outputTypeScript).toBeDefined();
156+ expect(schema.outputTypeScript!.length).toBeGreaterThan(0);
157157
158− expect(output["$defs"]).toBeDefined();
159− const defs = output["$defs"] as Record<string, unknown>;
158+ // typeScriptDefinitions should carry the relevant subset
159+ expect(schema.typeScriptDefinitions).toBeDefined();
160+ const defs = schema.typeScriptDefinitions!;
160161 expect(Object.keys(defs).length).toBeGreaterThan(0);
161162 expect(Object.keys(defs).length).toBeLessThan(100);
162163 }),