History

Update executor version to 1.1.6, enhance tests, and add new source templates

- Bump version in package.json - Add tests for completed MCP return values in server.test.ts - Introduce new source templates for Neon MCP and Neon API in source-templates.ts - Refactor OpenAPI response handling in openapi-tools.ts and related tests - Improve result preview formatting in executor-mcp

Rhys Sullivan committed 8 months ago

Commit 9f5d7cf1 parent cc270618 files · +179 −125Browse files

Unified · Split

8 files
  1. apps/executor/package.json +1 −1
  2. apps/executor/src/server/server.test.ts +43 −0
  3. apps/web/src/views/source-templates.ts +15 −0
  4. packages/codemode-openapi/src/openapi-tools.test.ts +56 −37
  5. packages/codemode-openapi/src/openapi-tools.ts +31 −64
  6. packages/control-plane/src/runtime/workspace-execution-environment.test.ts +6 −14
  7. packages/executor-mcp/src/index.ts +25 −2
  8. packages/kitchen-sink/src/source-runtime.test.ts +2 −7
apps/executor/package.json+1−1
@@ -1,6 +1,6 @@
11 {
22 "name": "executor",
3− "version": "1.1.5",
3+ "version": "1.1.6",
44 "description": "Local AI executor with a CLI, local API server, and web UI.",
55 "keywords": [
66 "executor",
apps/executor/src/server/server.test.ts+43−0
@@ -625,6 +625,39 @@ describe("local-executor-server", () => { · 624 unmodified lines
625625 }),
626626 );
627627
628+ it.scoped("includes completed MCP return values in text content", () =>
629+ Effect.gen(function* () {
630+ const server = yield* makeServer;
631+ const mcp = yield* makeExecutorMcpClient({
632+ baseUrl: server.baseUrl,
633+ });
634+
635+ const executed = yield* Effect.promise(
636+ () =>
637+ mcp.client.callTool({
638+ name: "execute",
639+ arguments: {
640+ code: "return await tools.math.add({ a: 20, b: 22 });",
641+ },
642+ }) as Promise<{
643+ content?: Array<{
644+ type?: string;
645+ text?: string;
646+ }>;
647+ structuredContent?: {
648+ status?: string;
649+ result?: unknown;
650+ };
651+ }>,
652+ );
653+
654+ expect(executed.structuredContent?.status).toBe("completed");
655+ expect(executed.structuredContent?.result).toEqual({ sum: 42 });
656+ expect(executed.content?.find((item) => item.type === "text")?.text).toContain("Result:");
657+ expect(executed.content?.find((item) => item.type === "text")?.text).toContain('"sum": 42');
658+ }),
659+ );
660+
628661 it.scoped("serves only execute over MCP when elicitation is supported", () =>
629662 Effect.gen(function* () {
630663 const server = yield* createLocalExecutorServer({
@@ -665,6 +698,10 @@ describe("local-executor-server", () => { · 34 unmodified lines
665698 code: 'return await tools.demo.gated_echo({ value: "from-mcp" });',
666699 },
667700 }) as Promise<{
701+ content?: Array<{
702+ type?: string;
703+ text?: string;
704+ }>;
668705 structuredContent?: {
669706 status?: string;
670707 result?: unknown;
@@ -674,6 +711,7 @@ describe("local-executor-server", () => { · 3 unmodified lines
674711
675712 expect(executed.structuredContent?.status).toBe("completed");
676713 expect(executed.structuredContent?.result).toBe("approved:from-mcp");
714+ expect(executed.content?.find((item) => item.type === "text")?.text).toContain("approved:from-mcp");
677715 }),
678716 );
679717
@@ -734,6 +772,10 @@ describe("local-executor-server", () => { · 54 unmodified lines
734772 },
735773 },
736774 }) as Promise<{
775+ content?: Array<{
776+ type?: string;
777+ text?: string;
778+ }>;
737779 structuredContent?: {
738780 status?: string;
739781 result?: unknown;
@@ -743,6 +785,7 @@ describe("local-executor-server", () => { · 3 unmodified lines
743785
744786 expect(resumed.structuredContent?.status).toBe("completed");
745787 expect(resumed.structuredContent?.result).toBe("approved:manual-mcp");
788+ expect(resumed.content?.find((item) => item.type === "text")?.text).toContain("approved:manual-mcp");
746789 }),
747790 );
748791
apps/web/src/views/source-templates.ts+15−0
@@ -33,6 +33,21 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 32 unmodified lines
3333 kind: "mcp",
3434 endpoint: "https://mcp.axiom.co/mcp",
3535 },
36+ {
37+ id: "neon-mcp",
38+ name: "Neon MCP",
39+ summary: "Manage Postgres databases, branches, and queries via MCP.",
40+ kind: "mcp",
41+ endpoint: "https://mcp.neon.tech/mcp",
42+ },
43+ {
44+ id: "neon-api",
45+ name: "Neon API",
46+ summary: "Projects, branches, endpoints, databases, and API keys.",
47+ kind: "openapi",
48+ endpoint: "https://console.neon.tech/api/v2",
49+ specUrl: "https://neon.com/api_spec/release/v2.json",
50+ },
3651 {
3752 id: "github-rest",
3853 name: "GitHub REST API",
packages/codemode-openapi/src/openapi-tools.test.ts+56−37
@@ -305,13 +305,8 @@ describe("openapi-tools", () => { · 304 unmodified lines
305305 );
306306
307307 expect(result).toEqual({
308− ok: true,
309− status: 200,
310− headers: expect.any(Object),
311− body: {
312− full_name: "octocat/hello-world",
313− include: null,
314− },
308+ full_name: "octocat/hello-world",
309+ include: null,
315310 });
316311 expect(server.requests).toHaveLength(1);
317312 expect(server.requests[0]?.path).toBe("/repos/octocat/hello-world");
@@ -340,13 +335,8 @@ describe("openapi-tools", () => { · 22 unmodified lines
340335 );
341336
342337 expect(getResult).toEqual({
343− ok: true,
344− status: 200,
345− headers: expect.any(Object),
346− body: {
347− full_name: "octocat/hello-world",
348− include: "all",
349− },
338+ full_name: "octocat/hello-world",
339+ include: "all",
350340 });
351341
352342 const postResult = yield* Effect.promise(() =>
@@ -358,15 +348,10 @@ describe("openapi-tools", () => { · 5 unmodified lines
358348 );
359349
360350 expect(postResult).toEqual({
361− ok: true,
362− status: 201,
363− headers: expect.any(Object),
364− body: {
365− created: true,
366− owner: "octocat",
367− repo: "hello-world",
368− body: { title: "Bug report" },
369− },
351+ created: true,
352+ owner: "octocat",
353+ repo: "hello-world",
354+ body: { title: "Bug report" },
370355 });
371356
372357 const missingBody = yield* Effect.either(
@@ -398,6 +383,50 @@ describe("openapi-tools", () => { · 25 unmodified lines
398383 }),
399384 );
400385
386+ it.effect("throws on non-2xx OpenAPI responses", () =>
387+ Effect.gen(function* () {
388+ const manifest = yield* extractOpenApiManifest("generated", generatedOpenApiSpec);
389+ const httpClientLayer = Layer.succeed(
390+ HttpClient.HttpClient,
391+ HttpClient.make((request) =>
392+ Effect.sync(() =>
393+ HttpClientResponse.fromWeb(
394+ request,
395+ new Response(
396+ JSON.stringify({ error: "missing" }),
397+ {
398+ status: 404,
399+ headers: {
400+ "content-type": "application/json",
401+ },
402+ },
403+ ),
404+ ),
405+ ),
406+ ),
407+ );
408+ const tools = createOpenApiToolsFromManifest({
409+ manifest,
410+ baseUrl: "https://example.com",
411+ namespace: "source.generated",
412+ httpClientLayer,
413+ });
414+ const getRepo = resolveToolExecutor(tools, "source.generated.repos.getRepo");
415+ const failure = yield* Effect.either(
416+ Effect.tryPromise({
417+ try: () => getRepo({ owner: "octocat", repo: "hello-world" }),
418+ catch: (error: unknown) =>
419+ error instanceof Error ? error : new Error(String(error)),
420+ }),
421+ );
422+
423+ assertTrue(failure._tag === "Left");
424+ if (failure._tag === "Left" && failure.left instanceof Error) {
425+ assertInclude(failure.left.message, "HTTP 404");
426+ }
427+ }),
428+ );
429+
401430 it.effect("preserves base-path URLs when invoking tools", () =>
402431 Effect.gen(function* () {
403432 const manifest = yield* extractOpenApiManifest("generated", generatedOpenApiSpec);
@@ -438,13 +467,8 @@ describe("openapi-tools", () => { · 34 unmodified lines
438467 );
439468
440469 expect(result).toEqual({
441− ok: true,
442− status: 200,
443− headers: expect.any(Object),
444− body: {
445− full_name: "octocat/hello-world",
446− include: null,
447− },
470+ full_name: "octocat/hello-world",
471+ include: null,
448472 });
449473
450474 expect(capturedUrl).toBe("https://example.com/api/v3/repos/octocat/hello-world");
@@ -488,13 +512,8 @@ describe("openapi-tools", () => { · 37 unmodified lines
488512 );
489513
490514 expect(result).toEqual({
491− ok: true,
492− status: 200,
493− headers: expect.any(Object),
494− body: {
495− full_name: "octocat/hello-world",
496− include: null,
497− },
515+ full_name: "octocat/hello-world",
516+ include: null,
498517 });
499518 expect(server.requests).toHaveLength(1);
500519 expect(server.requests[0]?.path).toBe("/repos/octocat/hello-world");
packages/codemode-openapi/src/openapi-tools.ts+31−64
@@ -40,44 +40,6 @@ export class OpenApiToolInvocationError extends Data.TaggedError( · 39 unmodified lines
4040 details: string | null;
4141 }> {}
4242
43−const BLOCKED_RESPONSE_HEADER_NAMES = new Set([
44− "authorization",
45− "authentication-info",
46− "cookie",
47− "proxy-authenticate",
48− "proxy-authorization",
49− "set-cookie",
50− "set-cookie2",
51− "www-authenticate",
52− "x-api-key",
53− "x-auth-token",
54− "x-csrf-token",
55−]);
56−
57−const NOISY_RESPONSE_HEADER_NAMES = new Set([
58− "alt-svc",
59− "cf-ray",
60− "server",
61− "traceparent",
62− "tracestate",
63− "via",
64− "x-cache",
65− "x-cache-hits",
66− "x-powered-by",
67− "x-request-id",
68− "x-runtime",
69− "x-served-by",
70− "x-trace-id",
71−]);
72−
73−const NOISY_RESPONSE_HEADER_PREFIXES = [
74− "cf-",
75− "trace-",
76− "x-amz-cf-",
77− "x-b3-",
78− "x-cloud-trace-",
79−];
80−
8143 const isRecord = (value: unknown): value is Record<string, unknown> =>
8244 typeof value === "object" && value !== null && !Array.isArray(value);
8345
@@ -244,27 +206,7 @@ const normalizeHttpUrl = (value: string): string => { · 160 unmodified lines
244206 }
245207 };
246208
247−const shouldDropResponseHeader = (headerName: string): boolean =>
248− BLOCKED_RESPONSE_HEADER_NAMES.has(headerName)
249− || NOISY_RESPONSE_HEADER_NAMES.has(headerName)
250− || NOISY_RESPONSE_HEADER_PREFIXES.some((prefix) => headerName.startsWith(prefix));
251−
252−const sanitizeResponseHeaders = (
253− headers: Readonly<Record<string, string>>,
254−): Record<string, string> => {
255− const sanitized: Record<string, string> = {};
256−
257− for (const [rawName, rawValue] of Object.entries(headers)) {
258− const name = rawName.trim().toLowerCase();
259− if (name.length === 0 || shouldDropResponseHeader(name)) {
260− continue;
261− }
262209
263− sanitized[name] = rawValue.length > 4000 ? `${rawValue.slice(0, 4000)}...` : rawValue;
264− }
265−
266− return sanitized;
267−};
268210
269211 const decodeHttpClientResponseBody = (
270212 response: Awaited<ReturnType<HttpClient.HttpClient["execute"]>> extends Effect.Effect<
@@ -292,6 +234,28 @@ const decodeHttpClientResponseBody = ( · 21 unmodified lines
292234 );
293235 };
294236
237+const summarizeHttpResponseBody = (body: unknown): string | null => {
238+ if (body === null || body === undefined) {
239+ return null;
240+ }
241+
242+ if (typeof body === "string") {
243+ const trimmed = body.trim();
244+ if (trimmed.length === 0) {
245+ return null;
246+ }
247+
248+ return trimmed.length > 500 ? `${trimmed.slice(0, 500)}...` : trimmed;
249+ }
250+
251+ try {
252+ const serialized = JSON.stringify(body);
253+ return serialized.length > 500 ? `${serialized.slice(0, 500)}...` : serialized;
254+ } catch {
255+ return String(body);
256+ }
257+};
258+
295259 const inputSchemaFromTypingJson = (inputSchemaJson: string | undefined) => {
296260 if (!inputSchemaJson) {
297261 return unknownInputSchema;
@@ -491,12 +455,15 @@ export const createOpenApiToolsFromManifest = ( · 193 unmodified lines
491455 );
492456 const body = yield* decodeHttpClientResponseBody(response);
493457
494− return {
495− ok: response.status >= 200 && response.status < 300,
496− status: response.status,
497− headers: sanitizeResponseHeaders(response.headers),
498− body,
499− };
458+ if (response.status < 200 || response.status >= 300) {
459+ throw new OpenApiToolInvocationError({
460+ operation: "http_response",
461+ message: `OpenAPI request failed with HTTP ${response.status}`,
462+ details: summarizeHttpResponseBody(body),
463+ });
464+ }
465+
466+ return body;
500467 }).pipe(
501468 Effect.provide(httpClientLayer),
502469 ),
packages/control-plane/src/runtime/workspace-execution-environment.test.ts+6−14
@@ -1136,15 +1136,11 @@ describe("workspace-execution-environment", () => { · 1135 unmodified lines
11361136 repo: "ai",
11371137 },
11381138 })) as {
1139− status: number;
1140− body: {
1141− ok: boolean;
1142− full_name: string;
1143− };
1139+ ok: boolean;
1140+ full_name: string;
11441141 };
11451142
1146− expect(invoked.status).toBe(200);
1147− expect(invoked.body).toEqual({
1143+ expect(invoked).toEqual({
11481144 ok: true,
11491145 full_name: "vercel/ai",
11501146 });
@@ -1383,15 +1379,11 @@ describe("workspace-execution-environment", () => { · 232 unmodified lines
13831379 },
13841380 },
13851381 })) as {
1386− status: number;
1387− body: {
1388− ok: boolean;
1389− full_name: string;
1390− };
1382+ ok: boolean;
1383+ full_name: string;
13911384 };
13921385
1393− expect(invoked.status).toBe(200);
1394− expect(invoked.body).toEqual({
1386+ expect(invoked).toEqual({
13951387 ok: true,
13961388 full_name: "vercel/ai",
13971389 });
packages/executor-mcp/src/index.ts+25−2
@@ -77,6 +77,24 @@ const parseJsonValue = (value: string | null): unknown => { · 76 unmodified lines
7777 }
7878 };
7979
80+const maxResultPreviewChars = 30_000;
81+
82+const truncateText = (value: string, maxLength: number): string =>
83+ value.length > maxLength
84+ ? `${value.slice(0, maxLength)}\n... [result preview truncated ${value.length - maxLength} chars]`
85+ : value;
86+
87+const formatResultPreview = (resultJson: string): string => {
88+ try {
89+ const parsed = JSON.parse(resultJson) as unknown;
90+ const serialized = typeof parsed === "string" ? parsed : JSON.stringify(parsed, null, 2) ?? String(parsed);
91+ return truncateText(serialized, maxResultPreviewChars);
92+ } catch {
93+ return truncateText(resultJson, maxResultPreviewChars);
94+ }
95+};
96+
97+
8098 const parseInteractionPayload = (
8199 interaction: ExecutionInteraction,
82100 ): ParsedInteractionPayload | null => {
@@ -205,8 +223,13 @@ const loadExecuteDescription = (runtime: SqlControlPlaneRuntime): Promise<string · 122 unmodified lines
205223
206224 const summarizeExecution = (execution: ExecutionEnvelope["execution"]): string => {
207225 switch (execution.status) {
208− case "completed":
209− return `Execution ${execution.id} completed.`;
226+ case "completed": {
227+ if (execution.resultJson === null) {
228+ return `Execution ${execution.id} completed.`;
229+ }
230+
231+ return `Execution ${execution.id} completed.\nResult:\n${formatResultPreview(execution.resultJson)}`;
232+ }
210233 case "failed":
211234 return execution.errorText
212235 ? `Execution ${execution.id} failed: ${execution.errorText}`
packages/kitchen-sink/src/source-runtime.test.ts+2−7
@@ -816,13 +816,8 @@ describe("source runtime", () => { · 815 unmodified lines
816816 },
817817 result: {
818818 ok: true,
819− status: 200,
820− headers: expect.any(Object),
821− body: {
822− ok: true,
823− path: "/repos/vercel/ai",
824− authorization: "Bearer ghp_from_keychain",
825− },
819+ path: "/repos/vercel/ai",
820+ authorization: "Bearer ghp_from_keychain",
826821 },
827822 });
828823 expect(yield* discoveryBacked.executeDescription).toBe(