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 @@
1{1{
2 "name": "executor",2 "name": "executor",
3 "version": "1.1.5",3 "version": "1.1.6",
4 "description": "Local AI executor with a CLI, local API server, and web UI.",4 "description": "Local AI executor with a CLI, local API server, and web UI.",
5 "keywords": [5 "keywords": [
6 "executor",6 "executor",
apps/executor/src/server/server.test.ts+43−0
@@ -625,6 +625,39 @@ describe("local-executor-server", () => { · 624 unmodified lines
625 }),625 }),
626 );626 );
627 627
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
628 it.scoped("serves only execute over MCP when elicitation is supported", () =>661 it.scoped("serves only execute over MCP when elicitation is supported", () =>
629 Effect.gen(function* () {662 Effect.gen(function* () {
630 const server = yield* createLocalExecutorServer({663 const server = yield* createLocalExecutorServer({
@@ -665,6 +698,10 @@ describe("local-executor-server", () => { · 34 unmodified lines
665 code: 'return await tools.demo.gated_echo({ value: "from-mcp" });',698 code: 'return await tools.demo.gated_echo({ value: "from-mcp" });',
666 },699 },
667 }) as Promise<{700 }) as Promise<{
701 content?: Array<{
702 type?: string;
703 text?: string;
704 }>;
668 structuredContent?: {705 structuredContent?: {
669 status?: string;706 status?: string;
670 result?: unknown;707 result?: unknown;
@@ -674,6 +711,7 @@ describe("local-executor-server", () => { · 3 unmodified lines
674 711
675 expect(executed.structuredContent?.status).toBe("completed");712 expect(executed.structuredContent?.status).toBe("completed");
676 expect(executed.structuredContent?.result).toBe("approved:from-mcp");713 expect(executed.structuredContent?.result).toBe("approved:from-mcp");
714 expect(executed.content?.find((item) => item.type === "text")?.text).toContain("approved:from-mcp");
677 }),715 }),
678 );716 );
679 717
@@ -734,6 +772,10 @@ describe("local-executor-server", () => { · 54 unmodified lines
734 },772 },
735 },773 },
736 }) as Promise<{774 }) as Promise<{
775 content?: Array<{
776 type?: string;
777 text?: string;
778 }>;
737 structuredContent?: {779 structuredContent?: {
738 status?: string;780 status?: string;
739 result?: unknown;781 result?: unknown;
@@ -743,6 +785,7 @@ describe("local-executor-server", () => { · 3 unmodified lines
743 785
744 expect(resumed.structuredContent?.status).toBe("completed");786 expect(resumed.structuredContent?.status).toBe("completed");
745 expect(resumed.structuredContent?.result).toBe("approved:manual-mcp");787 expect(resumed.structuredContent?.result).toBe("approved:manual-mcp");
788 expect(resumed.content?.find((item) => item.type === "text")?.text).toContain("approved:manual-mcp");
746 }),789 }),
747 );790 );
748 791
apps/web/src/views/source-templates.ts+15−0
@@ -33,6 +33,21 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 32 unmodified lines
33 kind: "mcp",33 kind: "mcp",
34 endpoint: "https://mcp.axiom.co/mcp",34 endpoint: "https://mcp.axiom.co/mcp",
35 },35 },
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 },
36 {51 {
37 id: "github-rest",52 id: "github-rest",
38 name: "GitHub REST API",53 name: "GitHub REST API",
packages/codemode-openapi/src/openapi-tools.test.ts+56−37
@@ -305,13 +305,8 @@ describe("openapi-tools", () => { · 304 unmodified lines
305 );305 );
306 306
307 expect(result).toEqual({307 expect(result).toEqual({
308 ok: true,308 full_name: "octocat/hello-world",
309 status: 200,309 include: null,
310 headers: expect.any(Object),
311 body: {
312 full_name: "octocat/hello-world",
313 include: null,
314 },
315 });310 });
316 expect(server.requests).toHaveLength(1);311 expect(server.requests).toHaveLength(1);
317 expect(server.requests[0]?.path).toBe("/repos/octocat/hello-world");312 expect(server.requests[0]?.path).toBe("/repos/octocat/hello-world");
@@ -340,13 +335,8 @@ describe("openapi-tools", () => { · 22 unmodified lines
340 );335 );
341 336
342 expect(getResult).toEqual({337 expect(getResult).toEqual({
343 ok: true,338 full_name: "octocat/hello-world",
344 status: 200,339 include: "all",
345 headers: expect.any(Object),
346 body: {
347 full_name: "octocat/hello-world",
348 include: "all",
349 },
350 });340 });
351 341
352 const postResult = yield* Effect.promise(() =>342 const postResult = yield* Effect.promise(() =>
@@ -358,15 +348,10 @@ describe("openapi-tools", () => { · 5 unmodified lines
358 );348 );
359 349
360 expect(postResult).toEqual({350 expect(postResult).toEqual({
361 ok: true,351 created: true,
362 status: 201,352 owner: "octocat",
363 headers: expect.any(Object),353 repo: "hello-world",
364 body: {354 body: { title: "Bug report" },
365 created: true,
366 owner: "octocat",
367 repo: "hello-world",
368 body: { title: "Bug report" },
369 },
370 });355 });
371 356
372 const missingBody = yield* Effect.either(357 const missingBody = yield* Effect.either(
@@ -398,6 +383,50 @@ describe("openapi-tools", () => { · 25 unmodified lines
398 }),383 }),
399 );384 );
400 385
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
401 it.effect("preserves base-path URLs when invoking tools", () =>430 it.effect("preserves base-path URLs when invoking tools", () =>
402 Effect.gen(function* () {431 Effect.gen(function* () {
403 const manifest = yield* extractOpenApiManifest("generated", generatedOpenApiSpec);432 const manifest = yield* extractOpenApiManifest("generated", generatedOpenApiSpec);
@@ -438,13 +467,8 @@ describe("openapi-tools", () => { · 34 unmodified lines
438 );467 );
439 468
440 expect(result).toEqual({469 expect(result).toEqual({
441 ok: true,470 full_name: "octocat/hello-world",
442 status: 200,471 include: null,
443 headers: expect.any(Object),
444 body: {
445 full_name: "octocat/hello-world",
446 include: null,
447 },
448 });472 });
449 473
450 expect(capturedUrl).toBe("https://example.com/api/v3/repos/octocat/hello-world");474 expect(capturedUrl).toBe("https://example.com/api/v3/repos/octocat/hello-world");
@@ -488,13 +512,8 @@ describe("openapi-tools", () => { · 37 unmodified lines
488 );512 );
489 513
490 expect(result).toEqual({514 expect(result).toEqual({
491 ok: true,515 full_name: "octocat/hello-world",
492 status: 200,516 include: null,
493 headers: expect.any(Object),
494 body: {
495 full_name: "octocat/hello-world",
496 include: null,
497 },
498 });517 });
499 expect(server.requests).toHaveLength(1);518 expect(server.requests).toHaveLength(1);
500 expect(server.requests[0]?.path).toBe("/repos/octocat/hello-world");519 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
40 details: string | null;40 details: string | null;
41}> {}41}> {}
42 42
43const 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
57const 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
73const NOISY_RESPONSE_HEADER_PREFIXES = [
74 "cf-",
75 "trace-",
76 "x-amz-cf-",
77 "x-b3-",
78 "x-cloud-trace-",
79];
80
81const isRecord = (value: unknown): value is Record<string, unknown> =>43const isRecord = (value: unknown): value is Record<string, unknown> =>
82 typeof value === "object" && value !== null && !Array.isArray(value);44 typeof value === "object" && value !== null && !Array.isArray(value);
83 45
@@ -244,27 +206,7 @@ const normalizeHttpUrl = (value: string): string => { · 160 unmodified lines
244 }206 }
245};207};
246 208
247const 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
252const 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 }
262 209
263 sanitized[name] = rawValue.length > 4000 ? `${rawValue.slice(0, 4000)}...` : rawValue;
264 }
265
266 return sanitized;
267};
268 210
269const decodeHttpClientResponseBody = (211const decodeHttpClientResponseBody = (
270 response: Awaited<ReturnType<HttpClient.HttpClient["execute"]>> extends Effect.Effect<212 response: Awaited<ReturnType<HttpClient.HttpClient["execute"]>> extends Effect.Effect<
@@ -292,6 +234,28 @@ const decodeHttpClientResponseBody = ( · 21 unmodified lines
292 );234 );
293};235};
294 236
237const 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
295const inputSchemaFromTypingJson = (inputSchemaJson: string | undefined) => {259const inputSchemaFromTypingJson = (inputSchemaJson: string | undefined) => {
296 if (!inputSchemaJson) {260 if (!inputSchemaJson) {
297 return unknownInputSchema;261 return unknownInputSchema;
@@ -491,12 +455,15 @@ export const createOpenApiToolsFromManifest = ( · 193 unmodified lines
491 );455 );
492 const body = yield* decodeHttpClientResponseBody(response);456 const body = yield* decodeHttpClientResponseBody(response);
493 457
494 return {458 if (response.status < 200 || response.status >= 300) {
495 ok: response.status >= 200 && response.status < 300,459 throw new OpenApiToolInvocationError({
496 status: response.status,460 operation: "http_response",
497 headers: sanitizeResponseHeaders(response.headers),461 message: `OpenAPI request failed with HTTP ${response.status}`,
498 body,462 details: summarizeHttpResponseBody(body),
499 };463 });
464 }
465
466 return body;
500 }).pipe(467 }).pipe(
501 Effect.provide(httpClientLayer),468 Effect.provide(httpClientLayer),
502 ),469 ),
packages/control-plane/src/runtime/workspace-execution-environment.test.ts+6−14
@@ -1136,15 +1136,11 @@ describe("workspace-execution-environment", () => { · 1135 unmodified lines
1136 repo: "ai",1136 repo: "ai",
1137 },1137 },
1138 })) as {1138 })) as {
1139 status: number;1139 ok: boolean;
1140 body: {1140 full_name: string;
1141 ok: boolean;
1142 full_name: string;
1143 };
1144 };1141 };
1145 1142
1146 expect(invoked.status).toBe(200);1143 expect(invoked).toEqual({
1147 expect(invoked.body).toEqual({
1148 ok: true,1144 ok: true,
1149 full_name: "vercel/ai",1145 full_name: "vercel/ai",
1150 });1146 });
@@ -1383,15 +1379,11 @@ describe("workspace-execution-environment", () => { · 232 unmodified lines
1383 },1379 },
1384 },1380 },
1385 })) as {1381 })) as {
1386 status: number;1382 ok: boolean;
1387 body: {1383 full_name: string;
1388 ok: boolean;
1389 full_name: string;
1390 };
1391 };1384 };
1392 1385
1393 expect(invoked.status).toBe(200);1386 expect(invoked).toEqual({
1394 expect(invoked.body).toEqual({
1395 ok: true,1387 ok: true,
1396 full_name: "vercel/ai",1388 full_name: "vercel/ai",
1397 });1389 });
packages/executor-mcp/src/index.ts+25−2
@@ -77,6 +77,24 @@ const parseJsonValue = (value: string | null): unknown => { · 76 unmodified lines
77 }77 }
78};78};
79 79
80const maxResultPreviewChars = 30_000;
81
82const 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
87const 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
80const parseInteractionPayload = (98const parseInteractionPayload = (
81 interaction: ExecutionInteraction,99 interaction: ExecutionInteraction,
82): ParsedInteractionPayload | null => {100): ParsedInteractionPayload | null => {
@@ -205,8 +223,13 @@ const loadExecuteDescription = (runtime: SqlControlPlaneRuntime): Promise<string · 122 unmodified lines
205 223
206const summarizeExecution = (execution: ExecutionEnvelope["execution"]): string => {224const summarizeExecution = (execution: ExecutionEnvelope["execution"]): string => {
207 switch (execution.status) {225 switch (execution.status) {
208 case "completed":226 case "completed": {
209 return `Execution ${execution.id} 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 }
210 case "failed":233 case "failed":
211 return execution.errorText234 return execution.errorText
212 ? `Execution ${execution.id} failed: ${execution.errorText}`235 ? `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
816 },816 },
817 result: {817 result: {
818 ok: true,818 ok: true,
819 status: 200,819 path: "/repos/vercel/ai",
820 headers: expect.any(Object),820 authorization: "Bearer ghp_from_keychain",
821 body: {
822 ok: true,
823 path: "/repos/vercel/ai",
824 authorization: "Bearer ghp_from_keychain",
825 },
826 },821 },
827 });822 });
828 expect(yield* discoveryBacked.executeDescription).toBe(823 expect(yield* discoveryBacked.executeDescription).toBe(