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
5Release v1.4
.changeset/pre.json+1−1
@@ -1,5 +1,5 @@
1{1{
2 "mode": "pre",2 "mode": "exit",
3 "tag": "beta",3 "tag": "beta",
4 "initialVersions": {4 "initialVersions": {
5 "executor": "1.4.0-beta.0",5 "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
35 isError: Schema.Boolean,35 isError: Schema.Boolean,
36});36});
37 37
38const ExecutionNotFoundError = Schema.Struct({38const ExecutionNotFoundError = Schema.TaggedStruct("ExecutionNotFoundError", {
39 _tag: Schema.Literal("ExecutionNotFoundError"),
40 executionId: Schema.String,39 executionId: Schema.String,
41}).annotations(HttpApiSchema.annotations({ status: 404 }));40}).annotations(HttpApiSchema.annotations({ status: 404 }));
42 41
packages/hosts/mcp/src/server.test.ts+634−717
@@ -1,18 +1,12 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "vitest";
2import { Effect } from "effect";2import { Effect } from "effect";
3import { Client } from "@modelcontextprotocol/sdk/client/index.js";3import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";4import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
5import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";5import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
6 7
7import {8import { FormElicitation, UrlElicitation } from "@executor/sdk";
8 FormElicitation,9import type { ExecutionEngine, ExecutionResult } from "@executor/execution";
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 10
17import { createExecutorMcpServer } from "./server";11import { createExecutorMcpServer } from "./server";
18 12
@@ -53,7 +47,7 @@ type TestHarness = { · 34 unmodified lines
53 */47 */
54const connect = async (48const connect = async (
55 engine: ExecutionEngine,49 engine: ExecutionEngine,
56 clientCapabilities: { elicitation?: { form?: object } } = {},50 clientCapabilities: ClientCapabilities = {},
57): Promise<TestHarness> => {51): Promise<TestHarness> => {
58 const mcpServer = await createExecutorMcpServer({ engine });52 const mcpServer = await createExecutorMcpServer({ engine });
59 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();53 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
@@ -80,280 +74,255 @@ const connect = async ( · 20 unmodified lines
80// ---------------------------------------------------------------------------74// ---------------------------------------------------------------------------
81 75
82describe("MCP host server — client with elicitation", () => {76describe("MCP host server — client with elicitation", () => {
83 it.effect(77 it("execute tool calls engine.execute and returns result", async () => {
84 "execute tool calls engine.execute and returns result",78 const engine = makeStubEngine({
85 () =>79 execute: async (code) => ({
86 Effect.promise(async () => {80 result: `ran: ${code}`,
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 }),81 }),
200 );82 });
201 83
202 it.effect(84 const { client, close } = await connect(engine, {
203 "empty form schema gets wrapped with minimal valid schema",85 elicitation: { form: {} },
204 () =>86 });
205 Effect.promise(async () => {87
206 let receivedSchema: unknown;88 try {
207 89 const result = await client.callTool({ name: "execute", arguments: { code: "1+1" } });
208 const engine = makeStubEngine({90 expect(result.content).toEqual([{ type: "text", text: "ran: 1+1" }]);
209 execute: async (_code, { onElicitation }) => {91 expect(result.isError).toBeFalsy();
210 const response = await Effect.runPromise(92 } finally {
211 onElicitation({93 await close();
212 toolId: "t" as any,94 }
213 args: {},95 });
214 request: new FormElicitation({96
215 message: "Just approve",97 it("form elicitation is bridged from engine to MCP client and back", async () => {
216 requestedSchema: {}, // empty — approval only98 const engine = makeStubEngine({
217 }),99 execute: async (code, { onElicitation }) => {
218 }),100 const response = await Effect.runPromise(
219 );101 onElicitation({
220 return { result: response.action };102 toolId: "test-tool" as any,
221 },103 args: { code },
222 });104 request: new FormElicitation({
223 105 message: "Approve this action?",
224 const { client, close } = await connect(engine, {106 requestedSchema: {
225 elicitation: { form: {} },107 type: "object",
226 });108 properties: {
227 109 approved: { type: "boolean" },
228 client.setRequestHandler(ElicitRequestSchema, async (request) => {110 },
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 },111 },
300 },112 }),
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 }),113 }),
318 });114 );
319 115 return {
320 const { client, close } = await connect(engine, {116 result:
321 elicitation: { form: {} },117 response.action === "accept" && response.content?.approved
322 });118 ? "approved"
323 119 : "denied",
324 try {120 };
325 const result = await client.callTool({121 },
326 name: "execute",122 });
327 arguments: { code: "bad" },123
328 });124 const { client, close } = await connect(engine, {
329 expect(result.isError).toBe(true);125 elicitation: { form: {} },
330 const text = (result.content as any)[0].text;126 });
331 expect(text).toContain("something broke");127
332 } finally {128 // Register a client-side handler that auto-accepts
333 await close();129 client.setRequestHandler(ElicitRequestSchema, async () => ({
334 }130 action: "accept" as const,
335 }),131 content: { approved: true },
336 );132 }));
337 133
338 it.effect(134 try {
339 "resume tool is hidden when client supports elicitation",135 const result = await client.callTool({
340 () =>136 name: "execute",
341 Effect.promise(async () => {137 arguments: { code: "do-it" },
342 const engine = makeStubEngine({});138 });
343 const { client, close } = await connect(engine, {139 expect(result.content).toEqual([{ type: "text", text: "approved" }]);
344 elicitation: { form: {} },140 } finally {
345 });141 await close();
346 142 }
347 try {143 });
348 const { tools } = await client.listTools();144
349 const names = tools.map((t) => t.name);145 it("form elicitation declined by client → engine sees decline", async () => {
350 expect(names).toContain("execute");146 const engine = makeStubEngine({
351 expect(names).not.toContain("resume");147 execute: async (code, { onElicitation }) => {
352 } finally {148 const response = await Effect.runPromise(
353 await close();149 onElicitation({
354 }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"],
355 }),291 }),
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 });
357});326});
358 327
359// ---------------------------------------------------------------------------328// ---------------------------------------------------------------------------
@@ -361,268 +330,234 @@ describe("MCP host server — client with elicitation", () => { · 1 unmodified line
361// ---------------------------------------------------------------------------330// ---------------------------------------------------------------------------
362 331
363describe("MCP host server — client without elicitation (pause/resume)", () => {332describe("MCP host server — client without elicitation (pause/resume)", () => {
364 it.effect(333 it("completed execution returns result directly", async () => {
365 "completed execution returns result directly",334 const engine = makeStubEngine({
366 () =>335 executeWithPause: async () => ({
367 Effect.promise(async () => {336 status: "completed",
368 const engine = makeStubEngine({337 result: { result: "done" },
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 }),338 }),
406 );339 });
407 340
408 it.effect(341 const { client, close } = await connect(engine);
409 "paused execution returns interaction metadata with executionId",342
410 () =>343 try {
411 Effect.promise(async () => {344 const result = await client.callTool({
412 const engine = makeStubEngine({345 name: "execute",
413 executeWithPause: async (): Promise<ExecutionResult> => ({346 arguments: { code: "ok" },
414 status: "paused",347 });
415 execution: {348 expect(result.content).toEqual([{ type: "text", text: "done" }]);
416 id: "exec_42",349 expect(result.isError).toBeFalsy();
417 elicitationContext: {350 } finally {
418 toolId: "t" as any,351 await close();
419 args: {},352 }
420 request: new FormElicitation({353 });
421 message: "Need approval",354
422 requestedSchema: {355 it("both execute and resume tools are visible", async () => {
423 type: "object",356 const engine = makeStubEngine({});
424 properties: { ok: { type: "boolean" } },357 const { client, close } = await connect(engine);
425 },358
426 }),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" } },
427 },383 },
428 resolve: () => {},384 }),
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 },385 },
468 });386 resolve: () => {},
469 387 completion: new Promise(() => {}), // never resolves in this test
470 const { client, close } = await connect(engine);388 },
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 }),389 }),
489 );390 });
490 391
491 it.effect(392 const { client, close } = await connect(engine);
492 "resume tool passes parsed content to engine",393
493 () =>394 try {
494 Effect.promise(async () => {395 const result = await client.callTool({
495 let receivedContent: Record<string, unknown> | undefined;396 name: "execute",
496 397 arguments: { code: "pause-me" },
497 const engine = makeStubEngine({398 });
498 resume: async (_id, response) => {399 const text = (result.content as Array<{ type: string; text: string }>)[0].text;
499 receivedContent = response.content;400 expect(text).toContain("exec_42");
500 return { result: "ok" };401 expect(text).toContain("Need approval");
501 },402 expect(result.isError).toBeFalsy();
502 });403
503 404 // structuredContent should contain the executionId
504 const { client, close } = await connect(engine);405 const structured = result.structuredContent as Record<string, unknown>;
505 406 expect(structured?.executionId).toBe("exec_42");
506 try {407 expect(structured?.status).toBe("waiting_for_interaction");
507 await client.callTool({408 } finally {
508 name: "resume",409 await close();
509 arguments: {410 }
510 executionId: "exec_1",411 });
511 action: "accept",412
512 content: JSON.stringify({ approved: true, name: "test" }),413 it("resume tool completes a paused execution", async () => {
513 },414 const engine = makeStubEngine({
514 });415 resume: async (executionId, response) => {
515 expect(receivedContent).toEqual({ approved: true, name: "test" });416 if (executionId === "exec_1" && response.action === "accept") {
516 } finally {417 return { result: "resumed-ok" };
517 await close();
518 }418 }
519 }),419 return null;
520 );420 },
521 421 });
522 it.effect(422
523 "resume with empty content passes undefined",423 const { client, close } = await connect(engine);
524 () =>424
525 Effect.promise(async () => {425 try {
526 let receivedContent: Record<string, unknown> | undefined = { marker: true };426 const result = await client.callTool({
527 427 name: "resume",
528 const engine = makeStubEngine({428 arguments: {
529 resume: async (_id, response) => {429 executionId: "exec_1",
530 receivedContent = response.content;430 action: "accept",
531 return { result: "ok" };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 }),
532 },535 },
533 });536 resolve: () => {},
534 537 completion: new Promise(() => {}),
535 const { client, close } = await connect(engine);538 },
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 }),539 }),
551 );540 });
552 541
553 it.effect(542 const { client, close } = await connect(engine);
554 "resume with unknown executionId returns error",543
555 () =>544 try {
556 Effect.promise(async () => {545 const result = await client.callTool({
557 const engine = makeStubEngine({546 name: "execute",
558 resume: async () => null,547 arguments: { code: "oauth" },
559 });548 });
560 549 const text = (result.content as Array<{ type: string; text: string }>)[0].text;
561 const { client, close } = await connect(engine);550 expect(text).toContain("https://auth.example.com/callback");
562 551 expect(text).toContain("exec_99");
563 try {552
564 const result = await client.callTool({553 const structured = result.structuredContent as Record<string, unknown>;
565 name: "resume",554 const interaction = structured?.interaction as Record<string, unknown>;
566 arguments: {555 expect(interaction?.kind).toBe("url");
567 executionId: "does-not-exist",556 expect(interaction?.url).toBe("https://auth.example.com/callback");
568 action: "accept",557 } finally {
569 content: "{}",558 await close();
570 },559 }
571 });560 });
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});561});
627 562
628// ---------------------------------------------------------------------------563// ---------------------------------------------------------------------------
@@ -630,54 +565,48 @@ describe("MCP host server — client without elicitation (pause/resume)", () => · 1 unmodified line
630// ---------------------------------------------------------------------------565// ---------------------------------------------------------------------------
631 566
632describe("MCP host server — elicitation error handling", () => {567describe("MCP host server — elicitation error handling", () => {
633 it.effect(568 it("elicitInput failure falls back to cancel", async () => {
634 "elicitInput failure falls back to cancel",569 const engine = makeStubEngine({
635 () =>570 execute: async (_code, { onElicitation }) => {
636 Effect.promise(async () => {571 const response = await Effect.runPromise(
637 const engine = makeStubEngine({572 onElicitation({
638 execute: async (_code, { onElicitation }) => {573 toolId: "t" as any,
639 const response = await Effect.runPromise(574 args: {},
640 onElicitation({575 request: new FormElicitation({
641 toolId: "t" as any,576 message: "will fail",
642 args: {},577 requestedSchema: {
643 request: new FormElicitation({578 type: "object",
644 message: "will fail",579 properties: { x: { type: "string" } },
645 requestedSchema: {580 },
646 type: "object",581 }),
647 properties: { x: { type: "string" } },582 }),
648 },583 );
649 }),584 return { result: `fallback:${response.action}` };
650 }),585 },
651 );586 });
652 return { result: `fallback:${response.action}` };587
653 },588 const { client, close } = await connect(engine, {
654 });589 elicitation: { form: {} },
655 590 });
656 const { client, close } = await connect(engine, {591
657 elicitation: { form: {} },592 // Client throws when it receives the elicitation — server should catch
658 });593 client.setRequestHandler(ElicitRequestSchema, async () => {
659 594 throw new Error("client cannot handle this");
660 // Don't register any handler — the client will reject with an error595 });
661 // when it receives an elicitation request it can't handle.596
662 // But the MCP SDK might auto-handle... let's explicitly throw.597 try {
663 client.setRequestHandler(ElicitRequestSchema, async () => {598 const result = await client.callTool({
664 throw new Error("client cannot handle this");599 name: "execute",
665 });600 arguments: { code: "fail" },
666 601 });
667 try {602 // The server catches the error and returns cancel
668 const result = await client.callTool({603 expect(result.content).toEqual([
669 name: "execute",604 { type: "text", text: "fallback:cancel" },
670 arguments: { code: "fail" },605 ]);
671 });606 } finally {
672 // The server catches the error and returns cancel607 await close();
673 expect(result.content).toEqual([608 }
674 { type: "text", text: "fallback:cancel" },609 });
675 ]);
676 } finally {
677 await close();
678 }
679 }),
680 );
681});610});
682 611
683// ---------------------------------------------------------------------------612// ---------------------------------------------------------------------------
@@ -685,70 +614,62 @@ describe("MCP host server — elicitation error handling", () => { · 1 unmodified line
685// ---------------------------------------------------------------------------614// ---------------------------------------------------------------------------
686 615
687describe("MCP host server — resume content parsing", () => {616describe("MCP host server — resume content parsing", () => {
688 it.effect(617 it("array JSON is rejected (not passed as content)", async () => {
689 "array JSON is rejected (not passed as content)",618 let receivedContent: Record<string, unknown> | undefined = { marker: true };
690 () =>619
691 Effect.promise(async () => {620 const engine = makeStubEngine({
692 let receivedContent: Record<string, unknown> | undefined = { marker: true };621 resume: async (_id, response) => {
693 622 receivedContent = response.content;
694 const engine = makeStubEngine({623 return { result: "ok" };
695 resume: async (_id, response) => {624 },
696 receivedContent = response.content;625 });
697 return { result: "ok" };626
698 },627 const { client, close } = await connect(engine);
699 });628
700 629 try {
701 const { client, close } = await connect(engine);630 await client.callTool({
702 631 name: "resume",
703 try {632 arguments: {
704 await client.callTool({633 executionId: "exec_1",
705 name: "resume",634 action: "accept",
706 arguments: {635 content: "[1,2,3]",
707 executionId: "exec_1",636 },
708 action: "accept",637 });
709 content: "[1,2,3]",638 // Array should be rejected — engine receives undefined
710 },639 expect(receivedContent).toBeUndefined();
711 });640 } finally {
712 // Array should be rejected — engine receives undefined641 await close();
713 expect(receivedContent).toBeUndefined();642 }
714 } finally {643 });
715 await close();644
716 }645 it("invalid JSON is handled gracefully (not thrown)", async () => {
717 }),646 let receivedContent: Record<string, unknown> | undefined = { marker: true };
718 );647
719 648 const engine = makeStubEngine({
720 it.effect(649 resume: async (_id, response) => {
721 "invalid JSON is handled gracefully (not thrown)",650 receivedContent = response.content;
722 () =>651 return { result: "ok" };
723 Effect.promise(async () => {652 },
724 let receivedContent: Record<string, unknown> | undefined = { marker: true };653 });
725 654
726 const engine = makeStubEngine({655 const { client, close } = await connect(engine);
727 resume: async (_id, response) => {656
728 receivedContent = response.content;657 try {
729 return { result: "ok" };658 const result = await client.callTool({
730 },659 name: "resume",
731 });660 arguments: {
732 661 executionId: "exec_1",
733 const { client, close } = await connect(engine);662 action: "accept",
734 663 content: "not-valid-json",
735 try {664 },
736 const result = await client.callTool({665 });
737 name: "resume",666 // Should not crash — invalid JSON treated as undefined content
738 arguments: {667 expect(receivedContent).toBeUndefined();
739 executionId: "exec_1",668 expect(result.isError).toBeFalsy();
740 action: "accept",669 } finally {
741 content: "not-valid-json",670 await close();
742 },671 }
743 });672 });
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});673});
753 674
754// ---------------------------------------------------------------------------675// ---------------------------------------------------------------------------
@@ -756,73 +677,69 @@ describe("MCP host server — resume content parsing", () => { · 1 unmodified line
756// ---------------------------------------------------------------------------677// ---------------------------------------------------------------------------
757 678
758describe("MCP host server — multiple elicitations", () => {679describe("MCP host server — multiple elicitations", () => {
759 it.effect(680 it("engine can elicit multiple times during a single execute call", async () => {
760 "engine can elicit multiple times during a single execute call",681 const engine = makeStubEngine({
761 () =>682 execute: async (_code, { onElicitation }) => {
762 Effect.promise(async () => {683 // First elicitation — ask for name
763 const engine = makeStubEngine({684 const r1 = await Effect.runPromise(
764 execute: async (_code, { onElicitation }) => {685 onElicitation({
765 // First elicitation — ask for name686 toolId: "t" as any,
766 const r1 = await Effect.runPromise(687 args: {},
767 onElicitation({688 request: new FormElicitation({
768 toolId: "t" as any,689 message: "What is your name?",
769 args: {},690 requestedSchema: {
770 request: new FormElicitation({691 type: "object",
771 message: "What is your name?",692 properties: { name: { type: "string" } },
772 requestedSchema: {693 },
773 type: "object",694 }),
774 properties: { name: { type: "string" } },695 }),
775 },696 );
776 }),697
777 }),698 // Second elicitation — ask for confirmation
778 );699 const r2 = await Effect.runPromise(
779 700 onElicitation({
780 // Second elicitation — ask for confirmation701 toolId: "t" as any,
781 const r2 = await Effect.runPromise(702 args: {},
782 onElicitation({703 request: new FormElicitation({
783 toolId: "t" as any,704 message: `Confirm: ${r1.content?.name}?`,
784 args: {},705 requestedSchema: {
785 request: new FormElicitation({706 type: "object",
786 message: `Confirm: ${r1.content?.name}?`,707 properties: { confirmed: { type: "boolean" } },
787 requestedSchema: {708 },
788 type: "object",709 }),
789 properties: { confirmed: { type: "boolean" } },710 }),
790 },711 );
791 }),712
792 }),713 return {
793 );714 result: `name=${r1.content?.name},confirmed=${r2.content?.confirmed}`,
794 715 };
795 return {716 },
796 result: `name=${r1.content?.name},confirmed=${r2.content?.confirmed}`,717 });
797 };718
798 },719 const { client, close } = await connect(engine, {
799 });720 elicitation: { form: {} },
800 721 });
801 const { client, close } = await connect(engine, {722
802 elicitation: { form: {} },723 let callCount = 0;
803 });724 client.setRequestHandler(ElicitRequestSchema, async () => {
804 725 callCount++;
805 let callCount = 0;726 if (callCount === 1) {
806 client.setRequestHandler(ElicitRequestSchema, async (request) => {727 return { action: "accept" as const, content: { name: "Alice" } };
807 callCount++;728 }
808 if (callCount === 1) {729 return { action: "accept" as const, content: { confirmed: true } };
809 return { action: "accept" as const, content: { name: "Alice" } };730 });
810 }731
811 return { action: "accept" as const, content: { confirmed: true } };732 try {
812 });733 const result = await client.callTool({
813 734 name: "execute",
814 try {735 arguments: { code: "multi" },
815 const result = await client.callTool({736 });
816 name: "execute",737 expect(result.content).toEqual([
817 arguments: { code: "multi" },738 { type: "text", text: "name=Alice,confirmed=true" },
818 });739 ]);
819 expect(result.content).toEqual([740 expect(callCount).toBe(2);
820 { type: "text", text: "name=Alice,confirmed=true" },741 } finally {
821 ]);742 await close();
822 expect(callCount).toBe(2);743 }
823 } finally {744 });
824 await close();
825 }
826 }),
827 );
828});745});
packages/plugins/openapi/src/sdk/real-specs.test.ts+6−5
@@ -150,13 +150,14 @@ describe("Real specs: Cloudflare API", () => { · 149 unmodified lines
150 expect(tools.length).toBeGreaterThan(0);150 expect(tools.length).toBeGreaterThan(0);
151 151
152 const schema = yield* executor.tools.schema(tools[0]!.id);152 const schema = yield* executor.tools.schema(tools[0]!.id);
153 const output = schema.outputSchema as Record<string, unknown>;
154 153
155 expect(output["$ref"]).toBeTypeOf("string");154 // outputTypeScript should reference a named type from definitions
156 expect((output["$ref"] as string).startsWith("#/$defs/")).toBe(true);155 expect(schema.outputTypeScript).toBeDefined();
156 expect(schema.outputTypeScript!.length).toBeGreaterThan(0);
157 157
158 expect(output["$defs"]).toBeDefined();158 // typeScriptDefinitions should carry the relevant subset
159 const defs = output["$defs"] as Record<string, unknown>;159 expect(schema.typeScriptDefinitions).toBeDefined();
160 const defs = schema.typeScriptDefinitions!;
160 expect(Object.keys(defs).length).toBeGreaterThan(0);161 expect(Object.keys(defs).length).toBeGreaterThan(0);
161 expect(Object.keys(defs).length).toBeLessThan(100);162 expect(Object.keys(defs).length).toBeLessThan(100);
162 }),163 }),