- 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
Unified · Split
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "executor", |
| 3 | − "version": "1.1.5", | |
| 3 | + "version": "1.1.6", | |
| 4 | 4 | "description": "Local AI executor with a CLI, local API server, and web UI.", |
| 5 | 5 | "keywords": [ |
| 6 | 6 | "executor", |
| @@ -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 | 661 | it.scoped("serves only execute over MCP when elicitation is supported", () => |
| 629 | 662 | Effect.gen(function* () { |
| 630 | 663 | const server = yield* createLocalExecutorServer({ |
| @@ -665,6 +698,10 @@ describe("local-executor-server", () => { · 34 unmodified lines | ||
| 665 | 698 | code: 'return await tools.demo.gated_echo({ value: "from-mcp" });', |
| 666 | 699 | }, |
| 667 | 700 | }) as Promise<{ |
| 701 | + content?: Array<{ | |
| 702 | + type?: string; | |
| 703 | + text?: string; | |
| 704 | + }>; | |
| 668 | 705 | structuredContent?: { |
| 669 | 706 | status?: string; |
| 670 | 707 | result?: unknown; |
| @@ -674,6 +711,7 @@ describe("local-executor-server", () => { · 3 unmodified lines | ||
| 674 | 711 | |
| 675 | 712 | expect(executed.structuredContent?.status).toBe("completed"); |
| 676 | 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 | 774 | }) as Promise<{ |
| 775 | + content?: Array<{ | |
| 776 | + type?: string; | |
| 777 | + text?: string; | |
| 778 | + }>; | |
| 737 | 779 | structuredContent?: { |
| 738 | 780 | status?: string; |
| 739 | 781 | result?: unknown; |
| @@ -743,6 +785,7 @@ describe("local-executor-server", () => { · 3 unmodified lines | ||
| 743 | 785 | |
| 744 | 786 | expect(resumed.structuredContent?.status).toBe("completed"); |
| 745 | 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 | |
| @@ -33,6 +33,21 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 32 unmodified lines | ||
| 33 | 33 | kind: "mcp", |
| 34 | 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 | 52 | id: "github-rest", |
| 38 | 53 | name: "GitHub REST API", |
| @@ -305,13 +305,8 @@ describe("openapi-tools", () => { · 304 unmodified lines | ||
| 305 | 305 | ); |
| 306 | 306 | |
| 307 | 307 | 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, | |
| 315 | 310 | }); |
| 316 | 311 | expect(server.requests).toHaveLength(1); |
| 317 | 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 | 337 | 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", | |
| 350 | 340 | }); |
| 351 | 341 | |
| 352 | 342 | const postResult = yield* Effect.promise(() => |
| @@ -358,15 +348,10 @@ describe("openapi-tools", () => { · 5 unmodified lines | ||
| 358 | 348 | ); |
| 359 | 349 | |
| 360 | 350 | 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" }, | |
| 370 | 355 | }); |
| 371 | 356 | |
| 372 | 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 | 430 | it.effect("preserves base-path URLs when invoking tools", () => |
| 402 | 431 | Effect.gen(function* () { |
| 403 | 432 | const manifest = yield* extractOpenApiManifest("generated", generatedOpenApiSpec); |
| @@ -438,13 +467,8 @@ describe("openapi-tools", () => { · 34 unmodified lines | ||
| 438 | 467 | ); |
| 439 | 468 | |
| 440 | 469 | 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, | |
| 448 | 472 | }); |
| 449 | 473 | |
| 450 | 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 | 514 | 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, | |
| 498 | 517 | }); |
| 499 | 518 | expect(server.requests).toHaveLength(1); |
| 500 | 519 | expect(server.requests[0]?.path).toBe("/repos/octocat/hello-world"); |
| @@ -40,44 +40,6 @@ export class OpenApiToolInvocationError extends Data.TaggedError( · 39 unmodified lines | ||
| 40 | 40 | details: string | null; |
| 41 | 41 | }> {} |
| 42 | 42 | |
| 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 | − | |
| 81 | 43 | const isRecord = (value: unknown): value is Record<string, unknown> => |
| 82 | 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 | |
| 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 | − } | |
| 262 | 209 | |
| 263 | − sanitized[name] = rawValue.length > 4000 ? `${rawValue.slice(0, 4000)}...` : rawValue; | |
| 264 | − } | |
| 265 | − | |
| 266 | − return sanitized; | |
| 267 | −}; | |
| 268 | 210 | |
| 269 | 211 | const decodeHttpClientResponseBody = ( |
| 270 | 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 | |
| 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 | + | |
| 295 | 259 | const inputSchemaFromTypingJson = (inputSchemaJson: string | undefined) => { |
| 296 | 260 | if (!inputSchemaJson) { |
| 297 | 261 | return unknownInputSchema; |
| @@ -491,12 +455,15 @@ export const createOpenApiToolsFromManifest = ( · 193 unmodified lines | ||
| 491 | 455 | ); |
| 492 | 456 | const body = yield* decodeHttpClientResponseBody(response); |
| 493 | 457 | |
| 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; | |
| 500 | 467 | }).pipe( |
| 501 | 468 | Effect.provide(httpClientLayer), |
| 502 | 469 | ), |
| @@ -1136,15 +1136,11 @@ describe("workspace-execution-environment", () => { · 1135 unmodified lines | ||
| 1136 | 1136 | repo: "ai", |
| 1137 | 1137 | }, |
| 1138 | 1138 | })) as { |
| 1139 | − status: number; | |
| 1140 | − body: { | |
| 1141 | − ok: boolean; | |
| 1142 | − full_name: string; | |
| 1143 | − }; | |
| 1139 | + ok: boolean; | |
| 1140 | + full_name: string; | |
| 1144 | 1141 | }; |
| 1145 | 1142 | |
| 1146 | − expect(invoked.status).toBe(200); | |
| 1147 | − expect(invoked.body).toEqual({ | |
| 1143 | + expect(invoked).toEqual({ | |
| 1148 | 1144 | ok: true, |
| 1149 | 1145 | full_name: "vercel/ai", |
| 1150 | 1146 | }); |
| @@ -1383,15 +1379,11 @@ describe("workspace-execution-environment", () => { · 232 unmodified lines | ||
| 1383 | 1379 | }, |
| 1384 | 1380 | }, |
| 1385 | 1381 | })) as { |
| 1386 | − status: number; | |
| 1387 | − body: { | |
| 1388 | − ok: boolean; | |
| 1389 | − full_name: string; | |
| 1390 | − }; | |
| 1382 | + ok: boolean; | |
| 1383 | + full_name: string; | |
| 1391 | 1384 | }; |
| 1392 | 1385 | |
| 1393 | − expect(invoked.status).toBe(200); | |
| 1394 | − expect(invoked.body).toEqual({ | |
| 1386 | + expect(invoked).toEqual({ | |
| 1395 | 1387 | ok: true, |
| 1396 | 1388 | full_name: "vercel/ai", |
| 1397 | 1389 | }); |
| @@ -77,6 +77,24 @@ const parseJsonValue = (value: string | null): unknown => { · 76 unmodified lines | ||
| 77 | 77 | } |
| 78 | 78 | }; |
| 79 | 79 | |
| 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 | + | |
| 80 | 98 | const parseInteractionPayload = ( |
| 81 | 99 | interaction: ExecutionInteraction, |
| 82 | 100 | ): ParsedInteractionPayload | null => { |
| @@ -205,8 +223,13 @@ const loadExecuteDescription = (runtime: SqlControlPlaneRuntime): Promise<string · 122 unmodified lines | ||
| 205 | 223 | |
| 206 | 224 | const summarizeExecution = (execution: ExecutionEnvelope["execution"]): string => { |
| 207 | 225 | 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 | + } | |
| 210 | 233 | case "failed": |
| 211 | 234 | return execution.errorText |
| 212 | 235 | ? `Execution ${execution.id} failed: ${execution.errorText}` |
| @@ -816,13 +816,8 @@ describe("source runtime", () => { · 815 unmodified lines | ||
| 816 | 816 | }, |
| 817 | 817 | result: { |
| 818 | 818 | 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", | |
| 826 | 821 | }, |
| 827 | 822 | }); |
| 828 | 823 | expect(yield* discoveryBacked.executeDescription).toBe( |