* feat(tool-result): add ToolResult union and dual-shape invoker Adds `ToolResult<T>` / `ToolError` to @executor-js/sdk and a `ToolResult.ok` / `ToolResult.fail` constructor pair. Updates the sandbox tool dispatcher to accept both the new typed union (passes through unchanged) and the legacy `{ data, error }` envelope. The legacy branch now walks known upstream shapes (Microsoft Graph, DealCloud, JSON:API, plain message-bearing bodies) before falling back to a clamped JSON.stringify of the body, so structured 4xx payloads no longer collapse to 'Tool execution failed'. The cause/defect branch keeps its strict .message-only discipline. * feat(plugins): emit ToolResult from openapi, graphql, mcp, google-discovery Migrates each dynamic plugin's invokeTool to wrap its result in the typed ToolResult<T> union. OpenAPI and Google Discovery map non-2xx responses to ToolResult.fail with an extracted upstream message and the raw body in error.details; 2xx responses to ToolResult.ok with { status, headers, data }. GraphQL maps 200-with-errors bodies to ToolResult.fail({ code: 'graphql_errors', ... }) and bare data to ToolResult.ok. MCP maps { isError: true } to ToolResult.fail and content arrays to ToolResult.ok. Static plugin tools (executor.openapi.previewSpec / addSource, executor.graphql.addSource) now also wrap their plain values in ToolResult.ok for shape consistency. Updates plugin tests that asserted on the old { status, headers, data, error } envelope or the bare MCP { content } shape. Tests that consume the OpenAPI plugin's invocation result use the new unwrapInvocation helper from @executor-js/plugin-openapi/testing. * feat(tool-result): make ToolResult mandatory and route defects opaquely Removes the legacy { data, error } envelope shim from the sandbox tool dispatcher. The invoker now passes ToolResult<T> through unchanged and wraps any other plain-value plugin return in ToolResult.ok so the sandbox surface is uniform. Plugin/infra defects no longer pass their message into the sandbox. The dispatcher generates a short hex correlation id, logs the full cause with that id under executor.correlation_id, and rejects with `Internal tool error [<corrId>]`. The QuickJS bridge and the dynamic-worker module template defensively re-stamp the same opaque shape; the MCP host server's top-level execute failure path does the same. ExecutionToolError in-band messages from the execution package's built-in validators (tools.search arg checks, etc.) are still passed through at the QuickJS bridge so model-facing input errors keep their useful diagnostic. Tests: - ToolResult.ok / fail / isToolResult constructor unit tests. - repro tests assert structured upstream payloads now reach the sandbox through ToolResult.error.details (not through .message). - leak tests pin the new invariant: plugin defects only escape as the opaque generic + correlation id; no token / connection string / file path leaks into Error.message. - QuickJS end-to-end defect test confirms the same shape at the sandbox boundary. - Cloud HTTP integration tests, MCP host tests, dynamic-worker invocation tests, and plugin tests updated for the new ToolResult wire shape and the opaque-generic defect contract. * Fix tool result contracts
Showing the changes to packages/plugins (19 of 35 files). Show all
Unified · Split
| @@ -1,6 +1,6 @@ | ||
| 1 | −// Reproduces the PR 706 bug class using Effect-native primitives only — | |
| 1 | +// Covers the Option JSON boundary using Effect-native primitives only: | |
| 2 | 2 | // no JSON.parse, no JSON.stringify, no node:fs on our side. We split the |
| 3 | −// JSON boundary into two Effect schema steps: | |
| 3 | +// boundary into two Effect schema steps: | |
| 4 | 4 | // |
| 5 | 5 | // 1. Schema.encodeEffect(Inner)(value) → encoded JS shape |
| 6 | 6 | // 2. Schema.encodeEffect(UnknownFromJsonString) → JSON string |
| @@ -13,7 +13,7 @@ · 6 unmodified lines | ||
| 13 | 13 | // step 2's JSON-stringify (driven by Effect, not us) flattens the Option |
| 14 | 14 | // to {_id,_tag,value}, and step 5 rejects the shape. |
| 15 | 15 | // |
| 16 | −// Run: vitest run packages/plugins/google-discovery/src/sdk/option-json-repro.test.ts | |
| 16 | +// Run: vitest run packages/plugins/google-discovery/src/sdk/option-json.test.ts | |
| 17 | 17 | |
| 18 | 18 | import { describe, expect, it } from "@effect/vitest"; |
| 19 | 19 | import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; |
| @@ -28,7 +28,7 @@ const fixed = { description: Option.some("hello") }; · 8 unmodified lines | ||
| 28 | 28 | const withTmpFile = <A, E>(fn: (path: string) => Effect.Effect<A, E, FileSystem.FileSystem>) => |
| 29 | 29 | Effect.gen(function* () { |
| 30 | 30 | const fs = yield* FileSystem.FileSystem; |
| 31 | − const dir = yield* fs.makeTempDirectoryScoped({ prefix: "option-repro-" }); | |
| 31 | + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "option-json-" }); | |
| 32 | 32 | return yield* fn(`${dir}/binding.json`); |
| 33 | 33 | }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)); |
| 34 | 34 | |
| @@ -430,10 +430,10 @@ describe("Google Discovery plugin", () => { · 429 unmodified lines | ||
| 430 | 430 | "drive.files.get", |
| 431 | 431 | { fileId: "123", fields: "id,name", prettyPrint: true }, |
| 432 | 432 | autoApprove, |
| 433 | − )) as { data: unknown; error: unknown }; | |
| 433 | + )) as { readonly ok: true; readonly data: { status: number; data: unknown } }; | |
| 434 | 434 | |
| 435 | − expect(invocation.error).toBeNull(); | |
| 436 | − expect(invocation.data).toEqual({ id: "123", name: "Quarterly Plan" }); | |
| 435 | + expect(invocation.ok).toBe(true); | |
| 436 | + expect(invocation.data.data).toEqual({ id: "123", name: "Quarterly Plan" }); | |
| 437 | 437 | |
| 438 | 438 | const apiRequest = handle.requests.find((request) => |
| 439 | 439 | request.url.startsWith("/drive/v3/files/123"), |
| @@ -3,6 +3,7 @@ import { Effect, Option, Predicate, Schema } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | import { |
| 4 | 4 | ScopeId, |
| 5 | 5 | SourceDetectionResult, |
| 6 | + ToolResult, | |
| 6 | 7 | Usage, |
| 7 | 8 | definePlugin, |
| 8 | 9 | resolveSecretBackedMap, |
| @@ -29,6 +30,69 @@ import type { · 20 unmodified lines | ||
| 29 | 30 | } from "./types"; |
| 30 | 31 | import { GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema } from "./types"; |
| 31 | 32 | |
| 33 | +// --------------------------------------------------------------------------- | |
| 34 | +// Upstream-error message extraction | |
| 35 | +// --------------------------------------------------------------------------- | |
| 36 | + | |
| 37 | +const GOOGLE_BODY_CAP = 1024; | |
| 38 | +const UpstreamMessageBody = Schema.Struct({ message: Schema.String }); | |
| 39 | +const UpstreamErrorMessageBody = Schema.Struct({ errorMessage: Schema.String }); | |
| 40 | +const UpstreamNestedErrorBody = Schema.Struct({ error: UpstreamMessageBody }); | |
| 41 | +const UpstreamErrorsArrayBody = Schema.Struct({ | |
| 42 | + errors: Schema.Array( | |
| 43 | + Schema.Struct({ | |
| 44 | + detail: Schema.optional(Schema.String), | |
| 45 | + message: Schema.optional(Schema.String), | |
| 46 | + title: Schema.optional(Schema.String), | |
| 47 | + }), | |
| 48 | + ), | |
| 49 | +}); | |
| 50 | + | |
| 51 | +const decodeUpstreamMessageBody = Schema.decodeUnknownOption(UpstreamMessageBody); | |
| 52 | +const decodeUpstreamErrorMessageBody = Schema.decodeUnknownOption(UpstreamErrorMessageBody); | |
| 53 | +const decodeUpstreamNestedErrorBody = Schema.decodeUnknownOption(UpstreamNestedErrorBody); | |
| 54 | +const decodeUpstreamErrorsArrayBody = Schema.decodeUnknownOption(UpstreamErrorsArrayBody); | |
| 55 | + | |
| 56 | +const googleClampedStringify = (value: unknown): string => { | |
| 57 | + let s: string; | |
| 58 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.stringify may throw on cycles; fall back to String() so the upstream body can still be surfaced as ToolError.details fallback text | |
| 59 | + try { | |
| 60 | + s = JSON.stringify(value); | |
| 61 | + } catch { | |
| 62 | + s = String(value); | |
| 63 | + } | |
| 64 | + return s.length > GOOGLE_BODY_CAP ? `${s.slice(0, GOOGLE_BODY_CAP)}…` : s; | |
| 65 | +}; | |
| 66 | + | |
| 67 | +const firstNonEmpty = (...values: readonly (string | undefined)[]): string | undefined => | |
| 68 | + values.find((value) => value !== undefined && value.length > 0); | |
| 69 | + | |
| 70 | +const googleExtractUpstreamMessage = (body: unknown, status: number): string => { | |
| 71 | + if (typeof body === "string") { | |
| 72 | + return body.length > 0 ? body : `Upstream returned HTTP ${status}`; | |
| 73 | + } | |
| 74 | + const nested = Option.getOrUndefined(decodeUpstreamNestedErrorBody(body)); | |
| 75 | + const messageBody = Option.getOrUndefined(decodeUpstreamMessageBody(body)); | |
| 76 | + const errorMessageBody = Option.getOrUndefined(decodeUpstreamErrorMessageBody(body)); | |
| 77 | + const errorsBody = Option.getOrUndefined(decodeUpstreamErrorsArrayBody(body)); | |
| 78 | + const arrayMessage = errorsBody?.errors | |
| 79 | + .map(({ detail, message: upstreamMessage, title }) => | |
| 80 | + firstNonEmpty(detail, upstreamMessage, title), | |
| 81 | + ) | |
| 82 | + .find((message) => message !== undefined); | |
| 83 | + const message = firstNonEmpty( | |
| 84 | + nested?.error.message, | |
| 85 | + messageBody?.message, | |
| 86 | + errorMessageBody?.errorMessage, | |
| 87 | + arrayMessage, | |
| 88 | + ); | |
| 89 | + if (message !== undefined) return message; | |
| 90 | + if (body !== null && typeof body === "object") { | |
| 91 | + return googleClampedStringify(body); | |
| 92 | + } | |
| 93 | + return `Upstream returned HTTP ${status}`; | |
| 94 | +}; | |
| 95 | + | |
| 32 | 96 | // --------------------------------------------------------------------------- |
| 33 | 97 | // Public input / output shapes |
| 34 | 98 | // --------------------------------------------------------------------------- |
| @@ -381,11 +445,27 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 346 unmodified lines | ||
| 381 | 445 | extension: makeGoogleDiscoveryPluginExtension, |
| 382 | 446 | |
| 383 | 447 | invokeTool: ({ ctx, toolRow, args }) => |
| 384 | − invokeGoogleDiscoveryTool({ | |
| 385 | − ctx: ctx as PluginCtx<GoogleDiscoveryStore>, | |
| 386 | − toolId: toolRow.id, | |
| 387 | − toolScope: decodeString(toolRow.scope_id), | |
| 388 | − args, | |
| 448 | + Effect.gen(function* () { | |
| 449 | + const result = yield* invokeGoogleDiscoveryTool({ | |
| 450 | + ctx: ctx as PluginCtx<GoogleDiscoveryStore>, | |
| 451 | + toolId: toolRow.id, | |
| 452 | + toolScope: decodeString(toolRow.scope_id), | |
| 453 | + args, | |
| 454 | + }); | |
| 455 | + const ok = result.status >= 200 && result.status < 300; | |
| 456 | + if (!ok) { | |
| 457 | + return ToolResult.fail({ | |
| 458 | + code: "upstream_http_error", | |
| 459 | + status: result.status, | |
| 460 | + message: googleExtractUpstreamMessage(result.error, result.status), | |
| 461 | + details: result.error, | |
| 462 | + }); | |
| 463 | + } | |
| 464 | + return ToolResult.ok({ | |
| 465 | + status: result.status, | |
| 466 | + headers: result.headers, | |
| 467 | + data: result.data, | |
| 468 | + }); | |
| 389 | 469 | }), |
| 390 | 470 | |
| 391 | 471 | resolveAnnotations: ({ ctx, sourceId, toolRows }) => |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { describe, it, expect } from "@effect/vitest"; |
| 2 | 2 | import { Effect, Predicate } from "effect"; |
| 3 | +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 3 | 4 | |
| 4 | 5 | import { |
| 5 | 6 | ConnectionId, |
| @@ -13,7 +14,7 @@ import { · 7 unmodified lines | ||
| 13 | 14 | SecretId, |
| 14 | 15 | TokenMaterial, |
| 15 | 16 | } from "@executor-js/sdk"; |
| 16 | −import { makeTestConfig } from "@executor-js/sdk/testing"; | |
| 17 | +import { makeTestConfig, serveTestHttpApp } from "@executor-js/sdk/testing"; | |
| 17 | 18 | import { memorySecretsPlugin } from "@executor-js/sdk/testing"; |
| 18 | 19 | |
| 19 | 20 | import { graphqlPlugin } from "./plugin"; |
| @@ -196,9 +197,8 @@ describe("graphqlPlugin real protocol server", () => { · 176 unmodified lines | ||
| 196 | 197 | }); |
| 197 | 198 | |
| 198 | 199 | expect(result).toEqual({ |
| 199 | − status: 200, | |
| 200 | + ok: true, | |
| 200 | 201 | data: { hello: "Hello Ada" }, |
| 201 | − errors: null, | |
| 202 | 202 | }); |
| 203 | 203 | |
| 204 | 204 | const requests = yield* server.requests; |
| @@ -209,6 +209,46 @@ describe("graphqlPlugin real protocol server", () => { · 4 unmodified lines | ||
| 209 | 209 | }), |
| 210 | 210 | ); |
| 211 | 211 | |
| 212 | + it.effect("surfaces non-2xx invocation responses as ToolResult.fail", () => | |
| 213 | + Effect.gen(function* () { | |
| 214 | + const server = yield* serveTestHttpApp((request) => | |
| 215 | + Effect.gen(function* () { | |
| 216 | + const webRequest = yield* HttpServerRequest.toWeb(request); | |
| 217 | + const body = yield* Effect.promise(() => webRequest.text()); | |
| 218 | + if (body.includes("__schema")) { | |
| 219 | + return HttpServerResponse.jsonUnsafe({ data: introspectionResult }); | |
| 220 | + } | |
| 221 | + return HttpServerResponse.text("temporary upstream outage", { | |
| 222 | + status: 503, | |
| 223 | + contentType: "text/plain", | |
| 224 | + }); | |
| 225 | + }), | |
| 226 | + ); | |
| 227 | + const executor = yield* createExecutor( | |
| 228 | + makeTestConfig({ plugins: [graphqlPlugin()] as const }), | |
| 229 | + ); | |
| 230 | + | |
| 231 | + yield* executor.graphql.addSource({ | |
| 232 | + endpoint: server.url("/graphql"), | |
| 233 | + scope: TEST_SCOPE, | |
| 234 | + namespace: "http_error_graph", | |
| 235 | + }); | |
| 236 | + | |
| 237 | + const result = yield* executor.tools.invoke("http_error_graph.query.hello", { | |
| 238 | + name: "Ada", | |
| 239 | + }); | |
| 240 | + | |
| 241 | + expect(result).toMatchObject({ | |
| 242 | + ok: false, | |
| 243 | + error: { | |
| 244 | + code: "graphql_http_error", | |
| 245 | + status: 503, | |
| 246 | + message: "GraphQL request failed with HTTP 503", | |
| 247 | + }, | |
| 248 | + }); | |
| 249 | + }), | |
| 250 | + ); | |
| 251 | + | |
| 212 | 252 | it.effect("invokes OAuth-backed sources with a bearer token", () => |
| 213 | 253 | Effect.gen(function* () { |
| 214 | 254 | const server = yield* serveGreetingServer; |
| @@ -251,9 +291,8 @@ describe("graphqlPlugin real protocol server", () => { · 36 unmodified lines | ||
| 251 | 291 | }); |
| 252 | 292 | |
| 253 | 293 | expect(result).toEqual({ |
| 254 | − status: 200, | |
| 294 | + ok: true, | |
| 255 | 295 | data: { hello: "Hello Ada" }, |
| 256 | − errors: null, | |
| 257 | 296 | }); |
| 258 | 297 | |
| 259 | 298 | const requests = yield* server.requests; |
| @@ -425,7 +464,7 @@ describe("graphqlPlugin", () => { · 165 unmodified lines | ||
| 425 | 464 | }, |
| 426 | 465 | { onElicitation: "accept-all" }, |
| 427 | 466 | ); |
| 428 | − expect(result).toEqual({ toolCount: 2, namespace: "via_static" }); | |
| 467 | + expect(result).toEqual({ ok: true, data: { toolCount: 2, namespace: "via_static" } }); | |
| 429 | 468 | expect(yield* executor.graphql.getSource("via_static", String(userScope))).toBeNull(); |
| 430 | 469 | expect((yield* executor.graphql.getSource("via_static", String(orgScope)))?.scope).toBe( |
| 431 | 470 | orgScope, |
| @@ -729,7 +768,7 @@ describe("graphqlPlugin", () => { · 297 unmodified lines | ||
| 729 | 768 | }); |
| 730 | 769 | |
| 731 | 770 | expect(result).toMatchObject({ |
| 732 | − status: 200, | |
| 771 | + ok: true, | |
| 733 | 772 | data: { hello: "Hello Ada" }, |
| 734 | 773 | }); |
| 735 | 774 | const requests = yield* server.requests; |
| @@ -843,7 +882,7 @@ describe("graphqlPlugin", () => { · 107 unmodified lines | ||
| 843 | 882 | }); |
| 844 | 883 | |
| 845 | 884 | expect(result).toMatchObject({ |
| 846 | − status: 200, | |
| 885 | + ok: true, | |
| 847 | 886 | data: { hello: "Hello Ada" }, |
| 848 | 887 | }); |
| 849 | 888 | const requests = yield* server.requests; |
| @@ -915,7 +954,7 @@ describe("graphqlPlugin", () => { · 65 unmodified lines | ||
| 915 | 954 | }); |
| 916 | 955 | |
| 917 | 956 | expect(result).toMatchObject({ |
| 918 | − status: 200, | |
| 957 | + ok: true, | |
| 919 | 958 | data: { hello: "Hello Ada" }, |
| 920 | 959 | }); |
| 921 | 960 | const requests = yield* server.requests; |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | ||
| 12 | 12 | SecretId, |
| 13 | 13 | SourceDetectionResult, |
| 14 | 14 | StorageError, |
| 15 | + ToolResult, | |
| 15 | 16 | type PluginCtx, |
| 16 | 17 | type StorageFailure, |
| 17 | 18 | type ToolAnnotations, |
| @@ -65,6 +66,19 @@ import { · 47 unmodified lines | ||
| 65 | 66 | // Plugin config |
| 66 | 67 | // --------------------------------------------------------------------------- |
| 67 | 68 | |
| 69 | +const GraphqlErrorBody = Schema.Struct({ message: Schema.String }); | |
| 70 | +const GraphqlErrorsBody = Schema.Array(Schema.Unknown); | |
| 71 | +const decodeGraphqlErrorBody = Schema.decodeUnknownOption(GraphqlErrorBody); | |
| 72 | +const decodeGraphqlErrorsBody = Schema.decodeUnknownOption(GraphqlErrorsBody); | |
| 73 | + | |
| 74 | +const decodeGraphqlErrors = (errors: unknown): readonly unknown[] | undefined => | |
| 75 | + Option.getOrUndefined(decodeGraphqlErrorsBody(errors)); | |
| 76 | + | |
| 77 | +const extractGraphqlErrorMessage = (errors: readonly unknown[]): string | undefined => | |
| 78 | + errors | |
| 79 | + .map((error) => Option.getOrUndefined(decodeGraphqlErrorBody(error))?.message) | |
| 80 | + .find((message) => message !== undefined && message.length > 0); | |
| 81 | + | |
| 68 | 82 | export type HeaderValue = HeaderValueValue; |
| 69 | 83 | export type GraphqlCredentialValue = ConfiguredGraphqlCredentialValue; |
| 70 | 84 | |
| @@ -994,7 +1008,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 923 unmodified lines | ||
| 994 | 1008 | }, |
| 995 | 1009 | inputSchema: StaticAddSourceInputStandardSchema, |
| 996 | 1010 | outputSchema: StaticAddSourceOutputStandardSchema, |
| 997 | − execute: (input) => self.addSource(input), | |
| 1011 | + execute: (input) => Effect.map(self.addSource(input), ToolResult.ok), | |
| 998 | 1012 | }), |
| 999 | 1013 | ], |
| 1000 | 1014 | }, |
| @@ -1057,7 +1071,28 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 56 unmodified lines | ||
| 1057 | 1071 | httpClientLayer, |
| 1058 | 1072 | ); |
| 1059 | 1073 | |
| 1060 | − return result; | |
| 1074 | + const errors = decodeGraphqlErrors(result.errors); | |
| 1075 | + if (errors !== undefined && errors.length > 0) { | |
| 1076 | + const firstMessage = extractGraphqlErrorMessage(errors); | |
| 1077 | + return ToolResult.fail({ | |
| 1078 | + code: "graphql_errors", | |
| 1079 | + message: firstMessage !== undefined ? firstMessage : "GraphQL request returned errors", | |
| 1080 | + details: { errors }, | |
| 1081 | + }); | |
| 1082 | + } | |
| 1083 | + if (result.status < 200 || result.status >= 300) { | |
| 1084 | + return ToolResult.fail({ | |
| 1085 | + code: "graphql_http_error", | |
| 1086 | + status: result.status, | |
| 1087 | + message: `GraphQL request failed with HTTP ${result.status}`, | |
| 1088 | + details: { | |
| 1089 | + status: result.status, | |
| 1090 | + data: result.data, | |
| 1091 | + errors: result.errors, | |
| 1092 | + }, | |
| 1093 | + }); | |
| 1094 | + } | |
| 1095 | + return ToolResult.ok(result.data); | |
| 1061 | 1096 | }), |
| 1062 | 1097 | |
| 1063 | 1098 | resolveAnnotations: ({ ctx, sourceId, toolRows }) => |
| @@ -55,6 +55,13 @@ import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types · 54 unmodified lines | ||
| 55 | 55 | |
| 56 | 56 | const ErrorMessage = Schema.Struct({ message: Schema.String }); |
| 57 | 57 | const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); |
| 58 | +const STDIO_ENV_ESCAPE_REPLACEMENTS: Readonly<Record<string, string>> = { | |
| 59 | + "\\": "\\", | |
| 60 | + n: "\n", | |
| 61 | + r: "\r", | |
| 62 | + t: "\t", | |
| 63 | + '"': '"', | |
| 64 | +}; | |
| 58 | 65 | |
| 59 | 66 | const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => |
| 60 | 67 | Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { |
| @@ -525,22 +532,10 @@ export default function AddMcpSource(props: { · 464 unmodified lines | ||
| 525 | 532 | const inner = value.slice(1, -1); |
| 526 | 533 | if (quote === "'") return inner; |
| 527 | 534 | |
| 528 | − return inner.replace(/\\([\\nrt"])/g, (_, escaped: string) => { | |
| 529 | − switch (escaped) { | |
| 530 | − case "\\": | |
| 531 | − return "\\"; | |
| 532 | − case "n": | |
| 533 | − return "\n"; | |
| 534 | − case "r": | |
| 535 | − return "\r"; | |
| 536 | − case "t": | |
| 537 | − return "\t"; | |
| 538 | − case '"': | |
| 539 | − return '"'; | |
| 540 | − default: | |
| 541 | − return escaped; | |
| 542 | − } | |
| 543 | − }); | |
| 535 | + return inner.replace( | |
| 536 | + /\\([\\nrt"])/g, | |
| 537 | + (_, escaped: string) => STDIO_ENV_ESCAPE_REPLACEMENTS[escaped] ?? escaped, | |
| 538 | + ); | |
| 544 | 539 | }; |
| 545 | 540 | |
| 546 | 541 | const parseStdioEnv = (raw: string): Record<string, string> | undefined => { |
| @@ -69,7 +69,8 @@ describe("MCP elicitation (end-to-end)", () => { · 68 unmodified lines | ||
| 69 | 69 | const result = yield* executor.tools.invoke(gatedEcho!.id, { value: "hello" }, options); |
| 70 | 70 | |
| 71 | 71 | expect(result).toMatchObject({ |
| 72 | − content: [{ type: "text", text: "approved:hello" }], | |
| 72 | + ok: true, | |
| 73 | + data: { content: [{ type: "text", text: "approved:hello" }] }, | |
| 73 | 74 | }); |
| 74 | 75 | // At least one elicitation should be the MCP server's form |
| 75 | 76 | expect(elicitationMessages.length).toBeGreaterThanOrEqual(1); |
| @@ -95,7 +96,8 @@ describe("MCP elicitation (end-to-end)", () => { · 19 unmodified lines | ||
| 95 | 96 | ); |
| 96 | 97 | |
| 97 | 98 | expect(result).toMatchObject({ |
| 98 | − content: [{ type: "text", text: "denied:nope" }], | |
| 99 | + ok: true, | |
| 100 | + data: { content: [{ type: "text", text: "denied:nope" }] }, | |
| 99 | 101 | }); |
| 100 | 102 | }), |
| 101 | 103 | ); |
| @@ -114,7 +116,32 @@ describe("MCP elicitation (end-to-end)", () => { · 12 unmodified lines | ||
| 114 | 116 | ); |
| 115 | 117 | |
| 116 | 118 | expect(result).toMatchObject({ |
| 117 | − content: [{ type: "text", text: "plain" }], | |
| 119 | + ok: true, | |
| 120 | + data: { content: [{ type: "text", text: "plain" }] }, | |
| 121 | + }); | |
| 122 | + }), | |
| 123 | + ); | |
| 124 | + | |
| 125 | + it.effect("successful tool invocation preserves structured MCP result fields", () => | |
| 126 | + Effect.gen(function* () { | |
| 127 | + const server = yield* serveElicitationTestServer; | |
| 128 | + const executor = yield* makeTestExecutor(server.url); | |
| 129 | + const tools = yield* executor.tools.list(); | |
| 130 | + const structuredEcho = tools.find((t) => t.name === "structured_echo")!; | |
| 131 | + | |
| 132 | + const result = yield* executor.tools.invoke( | |
| 133 | + structuredEcho.id, | |
| 134 | + { value: "plain" }, | |
| 135 | + { onElicitation: "accept-all" }, | |
| 136 | + ); | |
| 137 | + | |
| 138 | + expect(result).toMatchObject({ | |
| 139 | + ok: true, | |
| 140 | + data: { | |
| 141 | + content: [{ type: "text", text: "plain" }], | |
| 142 | + structuredContent: { value: "plain", upper: "PLAIN" }, | |
| 143 | + _meta: { trace: "kept" }, | |
| 144 | + }, | |
| 118 | 145 | }); |
| 119 | 146 | }), |
| 120 | 147 | ); |
| @@ -115,7 +115,8 @@ describe("per-user MCP auth isolation", () => { · 114 unmodified lines | ||
| 115 | 115 | { onElicitation: "accept-all" }, |
| 116 | 116 | ); |
| 117 | 117 | expect(userAResult).toMatchObject({ |
| 118 | − content: [{ type: "text", text: "ok:from-user-a" }], | |
| 118 | + ok: true, | |
| 119 | + data: { content: [{ type: "text", text: "ok:from-user-a" }] }, | |
| 119 | 120 | }); |
| 120 | 121 | expect( |
| 121 | 122 | (yield* server.requests) |
| @@ -187,7 +188,8 @@ describe("per-user MCP auth isolation", () => { · 65 unmodified lines | ||
| 187 | 188 | { onElicitation: "accept-all" }, |
| 188 | 189 | ); |
| 189 | 190 | expect(userAResult).toMatchObject({ |
| 190 | − content: [{ type: "text", text: "ok:user-a-header" }], | |
| 191 | + ok: true, | |
| 192 | + data: { content: [{ type: "text", text: "ok:user-a-header" }] }, | |
| 191 | 193 | }); |
| 192 | 194 | expect( |
| 193 | 195 | (yield* server.requests) |
| @@ -267,7 +269,8 @@ describe("per-user MCP auth isolation", () => { · 73 unmodified lines | ||
| 267 | 269 | ); |
| 268 | 270 | |
| 269 | 271 | expect(result).toMatchObject({ |
| 270 | − content: [{ type: "text", text: "ok:org-header" }], | |
| 272 | + ok: true, | |
| 273 | + data: { content: [{ type: "text", text: "ok:org-header" }] }, | |
| 271 | 274 | }); |
| 272 | 275 | const invokeRequests = (yield* server.requests).slice(beforeInvoke); |
| 273 | 276 | expect( |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | Predicate, |
| 9 | 9 | Result, |
| 10 | 10 | Scope, |
| 11 | + Schema, | |
| 11 | 12 | ScopedCache, |
| 12 | 13 | } from "effect"; |
| 13 | 14 | import type { HttpClient } from "effect/unstable/http"; |
| @@ -22,6 +23,7 @@ import { · 8 unmodified lines | ||
| 22 | 23 | ScopeId, |
| 23 | 24 | SecretId, |
| 24 | 25 | SourceDetectionResult, |
| 26 | + ToolResult, | |
| 25 | 27 | definePlugin, |
| 26 | 28 | resolveSecretBackedMap as resolveSharedSecretBackedMap, |
| 27 | 29 | type PluginCtx, |
| @@ -193,6 +195,24 @@ const toBinding = (entry: McpToolManifestEntry): McpToolBinding => · 165 unmodified lines | ||
| 193 | 195 | }); |
| 194 | 196 | |
| 195 | 197 | const MCP_PLUGIN_ID = "mcp"; |
| 198 | +const McpTextContent = Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }); | |
| 199 | +const McpToolCallEnvelope = Schema.Struct({ | |
| 200 | + isError: Schema.optional(Schema.Boolean), | |
| 201 | + content: Schema.optional(Schema.Array(Schema.Unknown)), | |
| 202 | +}); | |
| 203 | + | |
| 204 | +const decodeMcpTextContent = Schema.decodeUnknownOption(McpTextContent); | |
| 205 | +const decodeMcpToolCallEnvelope = Schema.decodeUnknownOption(McpToolCallEnvelope); | |
| 206 | + | |
| 207 | +const extractMcpErrorMessage = (content: unknown): string => { | |
| 208 | + if (Array.isArray(content)) { | |
| 209 | + for (const item of content) { | |
| 210 | + const decoded = Option.getOrUndefined(decodeMcpTextContent(item)); | |
| 211 | + if (decoded !== undefined && decoded.text.length > 0) return decoded.text; | |
| 212 | + } | |
| 213 | + } | |
| 214 | + return "MCP tool returned an error"; | |
| 215 | +}; | |
| 196 | 216 | |
| 197 | 217 | /** Match `token` as a separator-bounded run inside a URL hostname or path, |
| 198 | 218 | * used as a low-confidence detection hint when wire-shape detection fails. |
| @@ -1726,7 +1746,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 1527 unmodified lines | ||
| 1726 | 1746 | }); |
| 1727 | 1747 | } |
| 1728 | 1748 | |
| 1729 | − return yield* invokeMcpTool({ | |
| 1749 | + const raw = yield* invokeMcpTool({ | |
| 1730 | 1750 | toolId: toolRow.id, |
| 1731 | 1751 | toolName: entry.binding.toolName, |
| 1732 | 1752 | args, |
| @@ -1764,6 +1784,16 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 31 unmodified lines | ||
| 1764 | 1784 | pendingConnectors: runtime.pendingConnectors, |
| 1765 | 1785 | elicit, |
| 1766 | 1786 | }); |
| 1787 | + | |
| 1788 | + const envelope = Option.getOrUndefined(decodeMcpToolCallEnvelope(raw)); | |
| 1789 | + if (envelope?.isError === true) { | |
| 1790 | + return ToolResult.fail({ | |
| 1791 | + code: "mcp_tool_error", | |
| 1792 | + message: extractMcpErrorMessage(envelope.content), | |
| 1793 | + details: { content: envelope.content }, | |
| 1794 | + }); | |
| 1795 | + } | |
| 1796 | + return ToolResult.ok(raw); | |
| 1767 | 1797 | }).pipe( |
| 1768 | 1798 | Effect.withSpan("mcp.plugin.invoke_tool", { |
| 1769 | 1799 | attributes: { |
| @@ -375,6 +375,23 @@ export const makeElicitationMcpServer = () => { · 374 unmodified lines | ||
| 375 | 375 | }), |
| 376 | 376 | ); |
| 377 | 377 | |
| 378 | + server.registerTool( | |
| 379 | + "structured_echo", | |
| 380 | + { | |
| 381 | + description: "Returns text plus structured data", | |
| 382 | + inputSchema: { value: z.string() }, | |
| 383 | + outputSchema: { | |
| 384 | + value: z.string(), | |
| 385 | + upper: z.string(), | |
| 386 | + }, | |
| 387 | + }, | |
| 388 | + async ({ value }: { value: string }) => ({ | |
| 389 | + content: [{ type: "text" as const, text: value }], | |
| 390 | + structuredContent: { value, upper: value.toUpperCase() }, | |
| 391 | + _meta: { trace: "kept" }, | |
| 392 | + }), | |
| 393 | + ); | |
| 394 | + | |
| 378 | 395 | return server; |
| 379 | 396 | }; |
| 380 | 397 | |
| @@ -26,6 +26,7 @@ import { makeTestConfig, serveOAuthTestServer } from "@executor-js/sdk/testing"; · 25 unmodified lines | ||
| 26 | 26 | import { |
| 27 | 27 | addOpenApiTestSource, |
| 28 | 28 | serveOpenApiHttpApiTestServer, |
| 29 | + unwrapInvocation, | |
| 29 | 30 | } from "@executor-js/plugin-openapi/testing"; |
| 30 | 31 | |
| 31 | 32 | import { openApiPlugin } from "./plugin"; |
| @@ -226,25 +227,21 @@ describe("OpenAPI client_credentials OAuth", () => { · 194 unmodified lines | ||
| 226 | 227 | ); |
| 227 | 228 | // Invoking the tool injects the freshly-minted bearer via |
| 228 | 229 | // ctx.connections.accessToken. |
| 229 | − const result = (yield* userExec.tools.invoke( | |
| 230 | − "petstore.items.echoHeaders", | |
| 231 | − {}, | |
| 232 | − autoApprove, | |
| 233 | − )) as { | |
| 234 | − data: { authorization?: string } | null; | |
| 235 | − error: unknown; | |
| 236 | − }; | |
| 230 | + const result = unwrapInvocation( | |
| 231 | + yield* userExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove), | |
| 232 | + ); | |
| 237 | 233 | expect(result.error).toBeNull(); |
| 238 | − const bearer = result.data?.authorization?.replace(/^Bearer\s+/i, ""); | |
| 234 | + const data = result.data as EchoHeaders | null; | |
| 235 | + const bearer = data?.authorization?.replace(/^Bearer\s+/i, ""); | |
| 239 | 236 | expect(bearer).toBeDefined(); |
| 240 | 237 | expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true); |
| 241 | 238 | |
| 242 | 239 | // The connection lives at the innermost (user) scope, which |
| 243 | 240 | // preserves per-user credential resolution: if each user has |
| 244 | − // their own `dealcloud_client_id`/`dealcloud_client_secret` | |
| 245 | − // shadowed at their user scope, each user mints their own | |
| 246 | − // token. A single shared connection slot still lets every caller | |
| 247 | − // reach the right physical row through scoped credential bindings. | |
| 241 | + // their own OAuth client credentials shadowed at their user | |
| 242 | + // scope, each user mints their own token. A single shared | |
| 243 | + // connection slot still lets every caller reach the right | |
| 244 | + // physical row through scoped credential bindings. | |
| 248 | 245 | const userConnections = yield* userExec.connections.list(); |
| 249 | 246 | const connection = userConnections.find((c) => c.id === completedConnection.connectionId); |
| 250 | 247 | expect(connection).toBeDefined(); |
| @@ -38,6 +38,7 @@ import { · 37 unmodified lines | ||
| 38 | 38 | addOpenApiTestSource, |
| 39 | 39 | makeOpenApiTestSourceConfig, |
| 40 | 40 | serveOpenApiHttpApiTestServer, |
| 41 | + unwrapInvocation, | |
| 41 | 42 | } from "@executor-js/plugin-openapi/testing"; |
| 42 | 43 | |
| 43 | 44 | import { openApiPlugin } from "./plugin"; |
| @@ -277,25 +278,21 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 233 unmodified lines | ||
| 277 | 278 | // yields her token; bob's scope yields his. Same source, same |
| 278 | 279 | // tool, different injected bearer. |
| 279 | 280 | // ------------------------------------------------------------- |
| 280 | − const aliceResult = (yield* aliceExec.tools.invoke( | |
| 281 | − "vercel.projects.list", | |
| 282 | − {}, | |
| 283 | − autoApprove, | |
| 284 | − )) as { | |
| 285 | − data: { authorization?: string; token?: string } | null; | |
| 286 | − error: unknown; | |
| 287 | − }; | |
| 281 | + const aliceResult = unwrapInvocation( | |
| 282 | + yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove), | |
| 283 | + ); | |
| 288 | 284 | expect(aliceResult.error).toBeNull(); |
| 289 | − expect(aliceResult.data?.authorization).toBe("Bearer alice-vercel-token"); | |
| 290 | − expect(aliceResult.data?.token).toBe("alice-team"); | |
| 285 | + const aliceData = aliceResult.data as EchoHeaders | null; | |
| 286 | + expect(aliceData?.authorization).toBe("Bearer alice-vercel-token"); | |
| 287 | + expect(aliceData?.token).toBe("alice-team"); | |
| 291 | 288 | |
| 292 | − const bobResult = (yield* bobExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as { | |
| 293 | − data: { authorization?: string; token?: string } | null; | |
| 294 | − error: unknown; | |
| 295 | − }; | |
| 289 | + const bobResult = unwrapInvocation( | |
| 290 | + yield* bobExec.tools.invoke("vercel.projects.list", {}, autoApprove), | |
| 291 | + ); | |
| 296 | 292 | expect(bobResult.error).toBeNull(); |
| 297 | − expect(bobResult.data?.authorization).toBe("Bearer bob-vercel-token"); | |
| 298 | − expect(bobResult.data?.token).toBe("bob-team"); | |
| 293 | + const bobData = bobResult.data as EchoHeaders | null; | |
| 294 | + expect(bobData?.authorization).toBe("Bearer bob-vercel-token"); | |
| 295 | + expect(bobData?.token).toBe("bob-team"); | |
| 299 | 296 | |
| 300 | 297 | // ------------------------------------------------------------- |
| 301 | 298 | // 5. Scope attribution: each user's token is pinned to their |
| @@ -450,20 +447,19 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 148 unmodified lines | ||
| 450 | 447 | }), |
| 451 | 448 | ); |
| 452 | 449 | |
| 453 | − const aliceResult = (yield* aliceExec.tools.invoke( | |
| 454 | − "vercel.projects.list", | |
| 455 | − {}, | |
| 456 | − autoApprove, | |
| 457 | − )) as { data: { authorization?: string } | null; error: unknown }; | |
| 450 | + const aliceResult = unwrapInvocation( | |
| 451 | + yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove), | |
| 452 | + ); | |
| 458 | 453 | expect(aliceResult.error).toBeNull(); |
| 459 | − expect(aliceResult.data?.authorization).toBe("Bearer alice-vercel-token"); | |
| 454 | + expect((aliceResult.data as EchoHeaders | null)?.authorization).toBe( | |
| 455 | + "Bearer alice-vercel-token", | |
| 456 | + ); | |
| 460 | 457 | |
| 461 | − const bobResult = (yield* bobExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as { | |
| 462 | − data: { authorization?: string } | null; | |
| 463 | − error: unknown; | |
| 464 | − }; | |
| 458 | + const bobResult = unwrapInvocation( | |
| 459 | + yield* bobExec.tools.invoke("vercel.projects.list", {}, autoApprove), | |
| 460 | + ); | |
| 465 | 461 | expect(bobResult.error).toBeNull(); |
| 466 | − expect(bobResult.data?.authorization).toBe("Bearer bob-vercel-token"); | |
| 462 | + expect((bobResult.data as EchoHeaders | null)?.authorization).toBe("Bearer bob-vercel-token"); | |
| 467 | 463 | }), |
| 468 | 464 | ); |
| 469 | 465 | |
| @@ -558,13 +554,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 88 unmodified lines | ||
| 558 | 554 | }), |
| 559 | 555 | ); |
| 560 | 556 | |
| 561 | − const sharedResult = (yield* aliceExec.tools.invoke( | |
| 562 | − "vercel.projects.list", | |
| 563 | − {}, | |
| 564 | − autoApprove, | |
| 565 | − )) as { data: { authorization?: string } | null; error: unknown }; | |
| 557 | + const sharedResult = unwrapInvocation( | |
| 558 | + yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove), | |
| 559 | + ); | |
| 566 | 560 | expect(sharedResult.error).toBeNull(); |
| 567 | − expect(sharedResult.data?.authorization).toBe("Bearer org-token"); | |
| 561 | + expect((sharedResult.data as EchoHeaders | null)?.authorization).toBe("Bearer org-token"); | |
| 568 | 562 | |
| 569 | 563 | yield* aliceExec.secrets.set( |
| 570 | 564 | SetSecretInput.make({ |
| @@ -587,13 +581,13 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 16 unmodified lines | ||
| 587 | 581 | }), |
| 588 | 582 | ); |
| 589 | 583 | |
| 590 | − const overrideResult = (yield* aliceExec.tools.invoke( | |
| 591 | − "vercel.projects.list", | |
| 592 | − {}, | |
| 593 | − autoApprove, | |
| 594 | − )) as { data: { authorization?: string } | null; error: unknown }; | |
| 584 | + const overrideResult = unwrapInvocation( | |
| 585 | + yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove), | |
| 586 | + ); | |
| 595 | 587 | expect(overrideResult.error).toBeNull(); |
| 596 | − expect(overrideResult.data?.authorization).toBe("Bearer alice-token"); | |
| 588 | + expect((overrideResult.data as EchoHeaders | null)?.authorization).toBe( | |
| 589 | + "Bearer alice-token", | |
| 590 | + ); | |
| 597 | 591 | |
| 598 | 592 | yield* aliceExec.openapi.removeSourceBinding( |
| 599 | 593 | "vercel", |
| @@ -602,13 +596,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 2 unmodified lines | ||
| 602 | 596 | String(aliceScope.id), |
| 603 | 597 | ); |
| 604 | 598 | |
| 605 | − const fallbackResult = (yield* aliceExec.tools.invoke( | |
| 606 | − "vercel.projects.list", | |
| 607 | − {}, | |
| 608 | − autoApprove, | |
| 609 | − )) as { data: { authorization?: string } | null; error: unknown }; | |
| 599 | + const fallbackResult = unwrapInvocation( | |
| 600 | + yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove), | |
| 601 | + ); | |
| 610 | 602 | expect(fallbackResult.error).toBeNull(); |
| 611 | − expect(fallbackResult.data?.authorization).toBe("Bearer org-token"); | |
| 603 | + expect((fallbackResult.data as EchoHeaders | null)?.authorization).toBe("Bearer org-token"); | |
| 612 | 604 | |
| 613 | 605 | yield* aliceExec.openapi.setSourceBinding( |
| 614 | 606 | OpenApiSourceBindingInput.make({ |
| @@ -812,12 +804,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 197 unmodified lines | ||
| 812 | 804 | }), |
| 813 | 805 | ); |
| 814 | 806 | |
| 815 | − const result = (yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as { | |
| 816 | − data: { authorization?: string } | null; | |
| 817 | − error: unknown; | |
| 818 | − }; | |
| 807 | + const result = unwrapInvocation( | |
| 808 | + yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove), | |
| 809 | + ); | |
| 819 | 810 | expect(result.error).toBeNull(); |
| 820 | − expect(result.data?.authorization).toBe("Bearer alice-token"); | |
| 811 | + expect((result.data as EchoHeaders | null)?.authorization).toBe("Bearer alice-token"); | |
| 821 | 812 | }), |
| 822 | 813 | ); |
| 823 | 814 | |
| @@ -914,13 +905,12 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 90 unmodified lines | ||
| 914 | 905 | }), |
| 915 | 906 | ); |
| 916 | 907 | |
| 917 | − const result = (yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as { | |
| 918 | − data: { authorization?: string } | null; | |
| 919 | − error: unknown; | |
| 920 | − }; | |
| 908 | + const result = unwrapInvocation( | |
| 909 | + yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove), | |
| 910 | + ); | |
| 921 | 911 | |
| 922 | 912 | expect(result.error).toBeNull(); |
| 923 | − expect(result.data?.authorization).toBe("Bearer org-token"); | |
| 913 | + expect((result.data as EchoHeaders | null)?.authorization).toBe("Bearer org-token"); | |
| 924 | 914 | }), |
| 925 | 915 | ); |
| 926 | 916 | |
| @@ -1013,13 +1003,12 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 86 unmodified lines | ||
| 1013 | 1003 | }), |
| 1014 | 1004 | ); |
| 1015 | 1005 | |
| 1016 | − const result = (yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as { | |
| 1017 | − data: { authorization?: string } | null; | |
| 1018 | − error: unknown; | |
| 1019 | − }; | |
| 1006 | + const result = unwrapInvocation( | |
| 1007 | + yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove), | |
| 1008 | + ); | |
| 1020 | 1009 | |
| 1021 | 1010 | expect(result.error).toBeNull(); |
| 1022 | − expect(result.data?.authorization).toBe("Bearer org-choice"); | |
| 1011 | + expect((result.data as EchoHeaders | null)?.authorization).toBe("Bearer org-choice"); | |
| 1023 | 1012 | }), |
| 1024 | 1013 | ); |
| 1025 | 1014 | }); |
| @@ -28,6 +28,7 @@ import { makeTestConfig, serveOAuthTestServer } from "@executor-js/sdk/testing"; · 27 unmodified lines | ||
| 28 | 28 | import { |
| 29 | 29 | addOpenApiTestSource, |
| 30 | 30 | serveOpenApiHttpApiTestServer, |
| 31 | + unwrapInvocation, | |
| 31 | 32 | } from "@executor-js/plugin-openapi/testing"; |
| 32 | 33 | |
| 33 | 34 | import { openApiPlugin } from "./plugin"; |
| @@ -307,23 +308,25 @@ describe("OpenAPI multi-scope OAuth", () => { · 273 unmodified lines | ||
| 307 | 308 | // 4. Invoke through each exec — Authorization must carry that |
| 308 | 309 | // user's token. |
| 309 | 310 | // ------------------------------------------------------------- |
| 310 | − const aliceResult = (yield* aliceExec.tools.invoke( | |
| 311 | − "petstore.items.echoHeaders", | |
| 312 | − {}, | |
| 313 | − autoApprove, | |
| 314 | − )) as { data: { authorization?: string } | null; error: unknown }; | |
| 311 | + const aliceResult = unwrapInvocation( | |
| 312 | + yield* aliceExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove), | |
| 313 | + ); | |
| 315 | 314 | expect(aliceResult.error).toBeNull(); |
| 316 | − const aliceBearer = aliceResult.data?.authorization?.replace(/^Bearer\s+/i, ""); | |
| 315 | + const aliceBearer = (aliceResult.data as EchoHeaders | null)?.authorization?.replace( | |
| 316 | + /^Bearer\s+/i, | |
| 317 | + "", | |
| 318 | + ); | |
| 317 | 319 | expect(aliceBearer).toBeDefined(); |
| 318 | 320 | expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true); |
| 319 | 321 | |
| 320 | − const bobResult = (yield* bobExec.tools.invoke( | |
| 321 | − "petstore.items.echoHeaders", | |
| 322 | − {}, | |
| 323 | − autoApprove, | |
| 324 | − )) as { data: { authorization?: string } | null; error: unknown }; | |
| 322 | + const bobResult = unwrapInvocation( | |
| 323 | + yield* bobExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove), | |
| 324 | + ); | |
| 325 | 325 | expect(bobResult.error).toBeNull(); |
| 326 | − const bobBearer = bobResult.data?.authorization?.replace(/^Bearer\s+/i, ""); | |
| 326 | + const bobBearer = (bobResult.data as EchoHeaders | null)?.authorization?.replace( | |
| 327 | + /^Bearer\s+/i, | |
| 328 | + "", | |
| 329 | + ); | |
| 327 | 330 | expect(bobBearer).toBeDefined(); |
| 328 | 331 | expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true); |
| 329 | 332 | expect(bobBearer).not.toBe(aliceBearer); |
| @@ -610,23 +613,25 @@ describe("OpenAPI multi-scope OAuth", () => { · 280 unmodified lines | ||
| 610 | 613 | // (4) Each user's invocation resolves their OWN row and gets |
| 611 | 614 | // their OWN token — not whatever the last signer happened to |
| 612 | 615 | // mint. This is the core multi-user regression. |
| 613 | − const aliceResult = (yield* aliceExec.tools.invoke( | |
| 614 | − "petstore.items.echoHeaders", | |
| 615 | − {}, | |
| 616 | − autoApprove, | |
| 617 | − )) as { data: { authorization?: string } | null; error: unknown }; | |
| 616 | + const aliceResult = unwrapInvocation( | |
| 617 | + yield* aliceExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove), | |
| 618 | + ); | |
| 618 | 619 | expect(aliceResult.error).toBeNull(); |
| 619 | − const aliceBearer = aliceResult.data?.authorization?.replace(/^Bearer\s+/i, ""); | |
| 620 | + const aliceBearer = (aliceResult.data as EchoHeaders | null)?.authorization?.replace( | |
| 621 | + /^Bearer\s+/i, | |
| 622 | + "", | |
| 623 | + ); | |
| 620 | 624 | expect(aliceBearer).toBeDefined(); |
| 621 | 625 | expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true); |
| 622 | 626 | |
| 623 | − const bobResult = (yield* bobExec.tools.invoke( | |
| 624 | − "petstore.items.echoHeaders", | |
| 625 | − {}, | |
| 626 | − autoApprove, | |
| 627 | − )) as { data: { authorization?: string } | null; error: unknown }; | |
| 627 | + const bobResult = unwrapInvocation( | |
| 628 | + yield* bobExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove), | |
| 629 | + ); | |
| 628 | 630 | expect(bobResult.error).toBeNull(); |
| 629 | − const bobBearer = bobResult.data?.authorization?.replace(/^Bearer\s+/i, ""); | |
| 631 | + const bobBearer = (bobResult.data as EchoHeaders | null)?.authorization?.replace( | |
| 632 | + /^Bearer\s+/i, | |
| 633 | + "", | |
| 634 | + ); | |
| 630 | 635 | expect(bobBearer).toBeDefined(); |
| 631 | 636 | expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true); |
| 632 | 637 | expect(bobBearer).not.toBe(aliceBearer); |
| @@ -35,6 +35,7 @@ import { makeTestConfig, serveOAuthTestServer } from "@executor-js/sdk/testing"; · 34 unmodified lines | ||
| 35 | 35 | import { |
| 36 | 36 | addOpenApiTestSource, |
| 37 | 37 | serveOpenApiHttpApiTestServer, |
| 38 | + unwrapInvocation, | |
| 38 | 39 | } from "@executor-js/plugin-openapi/testing"; |
| 39 | 40 | |
| 40 | 41 | import { openApiPlugin } from "./plugin"; |
| @@ -253,17 +254,16 @@ describe("OpenAPI oauth refresh", () => { · 212 unmodified lines | ||
| 253 | 254 | }); |
| 254 | 255 | yield* bindOAuthConnection(executor, scopeId, "conn-refresh-ok", auth); |
| 255 | 256 | |
| 256 | − const result = (yield* executor.tools.invoke( | |
| 257 | − "petstore.items.echoHeaders", | |
| 258 | − {}, | |
| 259 | − autoApprove, | |
| 260 | − )) as { data: { authorization?: string } | null; error: unknown }; | |
| 257 | + const result = unwrapInvocation( | |
| 258 | + yield* executor.tools.invoke("petstore.items.echoHeaders", {}, autoApprove), | |
| 259 | + ); | |
| 261 | 260 | |
| 262 | 261 | expect(result.error).toBeNull(); |
| 262 | + const data = result.data as EchoHeaders | null; | |
| 263 | 263 | // Proves the refresh landed: invoke carried the fresh token, |
| 264 | 264 | // not the expired one we seeded. |
| 265 | − expect(result.data?.authorization).not.toBe("Bearer expired-access-v1"); | |
| 266 | − const bearer = result.data?.authorization?.replace(/^Bearer\s+/i, ""); | |
| 265 | + expect(data?.authorization).not.toBe("Bearer expired-access-v1"); | |
| 266 | + const bearer = data?.authorization?.replace(/^Bearer\s+/i, ""); | |
| 267 | 267 | expect(bearer).toBeDefined(); |
| 268 | 268 | expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true); |
| 269 | 269 | const calls = refreshTokenRequests(yield* oauth.requests); |
| @@ -313,12 +313,9 @@ describe("OpenAPI oauth refresh", () => { · 43 unmodified lines | ||
| 313 | 313 | ); |
| 314 | 314 | |
| 315 | 315 | for (const r of invokes) { |
| 316 | − const res = r as { | |
| 317 | − data: { authorization?: string } | null; | |
| 318 | − error: unknown; | |
| 319 | − }; | |
| 316 | + const res = unwrapInvocation(r); | |
| 320 | 317 | expect(res.error).toBeNull(); |
| 321 | − const bearer = res.data?.authorization?.replace(/^Bearer\s+/i, ""); | |
| 318 | + const bearer = (res.data as EchoHeaders | null)?.authorization?.replace(/^Bearer\s+/i, ""); | |
| 322 | 319 | expect(bearer).toBeDefined(); |
| 323 | 320 | expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true); |
| 324 | 321 | } |
| @@ -25,6 +25,7 @@ import { · 24 unmodified lines | ||
| 25 | 25 | addOpenApiTestSource, |
| 26 | 26 | makeOpenApiHttpApiTestSourceConfig, |
| 27 | 27 | serveOpenApiHttpApiTestServer, |
| 28 | + unwrapInvocation, | |
| 28 | 29 | } from "../testing"; |
| 29 | 30 | |
| 30 | 31 | const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; |
| @@ -267,13 +268,15 @@ describe("OpenAPI Plugin", () => { · 236 unmodified lines | ||
| 267 | 268 | }), |
| 268 | 269 | ); |
| 269 | 270 | |
| 270 | − const result = (yield* executor.tools.invoke( | |
| 271 | − "executor.openapi.previewSpec", | |
| 272 | − { spec: testApiSpec() }, | |
| 273 | − autoApprove, | |
| 274 | − )) as { operationCount: number }; | |
| 271 | + const preview = unwrapInvocation( | |
| 272 | + yield* executor.tools.invoke( | |
| 273 | + "executor.openapi.previewSpec", | |
| 274 | + { spec: testApiSpec() }, | |
| 275 | + autoApprove, | |
| 276 | + ), | |
| 277 | + ).data as { operationCount: number }; | |
| 275 | 278 | |
| 276 | − expect(result.operationCount).toBeGreaterThanOrEqual(2); | |
| 279 | + expect(preview.operationCount).toBeGreaterThanOrEqual(2); | |
| 277 | 280 | }), |
| 278 | 281 | ); |
| 279 | 282 | |
| @@ -316,11 +319,13 @@ describe("OpenAPI Plugin", () => { · 36 unmodified lines | ||
| 316 | 319 | }), |
| 317 | 320 | ); |
| 318 | 321 | |
| 319 | − const result = (yield* executor.tools.invoke( | |
| 320 | − "executor.openapi.addSource", | |
| 321 | − testApiSourceConfig({ scope: String(orgScope), namespace: "runtime" }), | |
| 322 | − autoApprove, | |
| 323 | − )) as { sourceId: string; toolCount: number }; | |
| 322 | + const result = unwrapInvocation( | |
| 323 | + yield* executor.tools.invoke( | |
| 324 | + "executor.openapi.addSource", | |
| 325 | + testApiSourceConfig({ scope: String(orgScope), namespace: "runtime" }), | |
| 326 | + autoApprove, | |
| 327 | + ), | |
| 328 | + ).data as { sourceId: string; toolCount: number }; | |
| 324 | 329 | |
| 325 | 330 | expect(result).toEqual({ sourceId: "runtime", toolCount: 4 }); |
| 326 | 331 | expect(yield* executor.openapi.getSource("runtime", String(userScope))).toBeNull(); |
| @@ -557,17 +562,12 @@ describe("OpenAPI Plugin", () => { · 230 unmodified lines | ||
| 557 | 562 | }, |
| 558 | 563 | }); |
| 559 | 564 | |
| 560 | − const result = (yield* executor.tools.invoke( | |
| 561 | − "authed.items.echoHeaders", | |
| 562 | − {}, | |
| 563 | − autoApprove, | |
| 564 | − )) as { | |
| 565 | − data: { authorization?: string; "x-static"?: string } | null; | |
| 566 | − error: unknown; | |
| 567 | − }; | |
| 565 | + const result = unwrapInvocation( | |
| 566 | + yield* executor.tools.invoke("authed.items.echoHeaders", {}, autoApprove), | |
| 567 | + ); | |
| 568 | 568 | |
| 569 | 569 | expect(result.error).toBeNull(); |
| 570 | − const data = result.data!; | |
| 570 | + const data = result.data as { authorization?: string; "x-static"?: string }; | |
| 571 | 571 | expect(data.authorization).toBe("Bearer secret-value-123"); |
| 572 | 572 | expect(data["x-static"]).toBe("hello"); |
| 573 | 573 | }), |
| @@ -751,10 +751,9 @@ describe("OpenAPI Plugin", () => { · 177 unmodified lines | ||
| 751 | 751 | namespace: "test", |
| 752 | 752 | }); |
| 753 | 753 | |
| 754 | − const result = (yield* executor.tools.invoke("test.items.listItems", {}, autoApprove)) as { | |
| 755 | − data: unknown; | |
| 756 | − error: unknown; | |
| 757 | − }; | |
| 754 | + const result = unwrapInvocation( | |
| 755 | + yield* executor.tools.invoke("test.items.listItems", {}, autoApprove), | |
| 756 | + ); | |
| 758 | 757 | expect(result.error).toBeNull(); |
| 759 | 758 | expect(result.data).toEqual(ITEMS); |
| 760 | 759 | }), |
| @@ -780,11 +779,9 @@ describe("OpenAPI Plugin", () => { · 19 unmodified lines | ||
| 780 | 779 | namespace: "test", |
| 781 | 780 | }); |
| 782 | 781 | |
| 783 | − const result = (yield* executor.tools.invoke( | |
| 784 | − "test.items.getItem", | |
| 785 | − { itemId: "2" }, | |
| 786 | − autoApprove, | |
| 787 | − )) as { data: unknown; error: unknown }; | |
| 782 | + const result = unwrapInvocation( | |
| 783 | + yield* executor.tools.invoke("test.items.getItem", { itemId: "2" }, autoApprove), | |
| 784 | + ); | |
| 788 | 785 | expect(result.error).toBeNull(); |
| 789 | 786 | expect(result.data).toEqual({ id: 2, name: "Gadget" }); |
| 790 | 787 | }), |
| @@ -810,16 +807,18 @@ describe("OpenAPI Plugin", () => { · 19 unmodified lines | ||
| 810 | 807 | namespace: "records", |
| 811 | 808 | }); |
| 812 | 809 | |
| 813 | − const result = (yield* executor.tools.invoke( | |
| 814 | − "records.items.queryRows", | |
| 815 | − { | |
| 816 | − entryTypeId: "18538", | |
| 817 | − query: JSON.stringify([{ DisplayName: "Example" }]), | |
| 818 | − limit: 10, | |
| 819 | − skip: 0, | |
| 820 | − }, | |
| 821 | − autoApprove, | |
| 822 | − )) as { data: unknown; error: unknown }; | |
| 810 | + const result = unwrapInvocation( | |
| 811 | + yield* executor.tools.invoke( | |
| 812 | + "records.items.queryRows", | |
| 813 | + { | |
| 814 | + entryTypeId: "18538", | |
| 815 | + query: JSON.stringify([{ DisplayName: "Example" }]), | |
| 816 | + limit: 10, | |
| 817 | + skip: 0, | |
| 818 | + }, | |
| 819 | + autoApprove, | |
| 820 | + ), | |
| 821 | + ); | |
| 823 | 822 | |
| 824 | 823 | expect(result.data).toBeNull(); |
| 825 | 824 | expect(result.error).toEqual( |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | SecretId, |
| 8 | 8 | SourceDetectionResult, |
| 9 | 9 | StorageError, |
| 10 | + ToolResult, | |
| 10 | 11 | definePlugin, |
| 11 | 12 | tool, |
| 12 | 13 | resolveSecretBackedMap, |
| @@ -57,6 +58,77 @@ import { · 44 unmodified lines | ||
| 57 | 58 | // Plugin config |
| 58 | 59 | // --------------------------------------------------------------------------- |
| 59 | 60 | |
| 61 | +const STRINGIFIED_BODY_CAP = 1024; | |
| 62 | +const UpstreamMessageBody = Schema.Struct({ message: Schema.String }); | |
| 63 | +const UpstreamErrorMessageBody = Schema.Struct({ errorMessage: Schema.String }); | |
| 64 | +const UpstreamNestedErrorBody = Schema.Struct({ error: UpstreamMessageBody }); | |
| 65 | +const UpstreamErrorsArrayBody = Schema.Struct({ | |
| 66 | + errors: Schema.Array( | |
| 67 | + Schema.Struct({ | |
| 68 | + detail: Schema.optional(Schema.String), | |
| 69 | + message: Schema.optional(Schema.String), | |
| 70 | + title: Schema.optional(Schema.String), | |
| 71 | + }), | |
| 72 | + ), | |
| 73 | +}); | |
| 74 | +const UpstreamDescriptionBody = Schema.Struct({ | |
| 75 | + detail: Schema.optional(Schema.String), | |
| 76 | + title: Schema.optional(Schema.String), | |
| 77 | + description: Schema.optional(Schema.String), | |
| 78 | +}); | |
| 79 | + | |
| 80 | +const decodeUpstreamMessageBody = Schema.decodeUnknownOption(UpstreamMessageBody); | |
| 81 | +const decodeUpstreamErrorMessageBody = Schema.decodeUnknownOption(UpstreamErrorMessageBody); | |
| 82 | +const decodeUpstreamNestedErrorBody = Schema.decodeUnknownOption(UpstreamNestedErrorBody); | |
| 83 | +const decodeUpstreamErrorsArrayBody = Schema.decodeUnknownOption(UpstreamErrorsArrayBody); | |
| 84 | +const decodeUpstreamDescriptionBody = Schema.decodeUnknownOption(UpstreamDescriptionBody); | |
| 85 | + | |
| 86 | +const clampedStringify = (value: unknown): string => { | |
| 87 | + let s: string; | |
| 88 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.stringify may throw on cycles; fall back to String() so the upstream body can still be surfaced as ToolError.details fallback text | |
| 89 | + try { | |
| 90 | + s = JSON.stringify(value); | |
| 91 | + } catch { | |
| 92 | + s = String(value); | |
| 93 | + } | |
| 94 | + return s.length > STRINGIFIED_BODY_CAP ? `${s.slice(0, STRINGIFIED_BODY_CAP)}…` : s; | |
| 95 | +}; | |
| 96 | + | |
| 97 | +const firstNonEmpty = (...values: readonly (string | undefined)[]): string | undefined => | |
| 98 | + values.find((value) => value !== undefined && value.length > 0); | |
| 99 | + | |
| 100 | +// Walk known upstream error-body shapes so ToolError.message stays concise | |
| 101 | +// while ToolError.details preserves the original body. | |
| 102 | +const extractUpstreamMessage = (body: unknown, status: number): string => { | |
| 103 | + if (typeof body === "string") { | |
| 104 | + return body.length > 0 ? body : `Upstream returned HTTP ${status}`; | |
| 105 | + } | |
| 106 | + const nested = Option.getOrUndefined(decodeUpstreamNestedErrorBody(body)); | |
| 107 | + const messageBody = Option.getOrUndefined(decodeUpstreamMessageBody(body)); | |
| 108 | + const errorMessageBody = Option.getOrUndefined(decodeUpstreamErrorMessageBody(body)); | |
| 109 | + const errorsBody = Option.getOrUndefined(decodeUpstreamErrorsArrayBody(body)); | |
| 110 | + const descriptionBody = Option.getOrUndefined(decodeUpstreamDescriptionBody(body)); | |
| 111 | + const arrayMessage = errorsBody?.errors | |
| 112 | + .map(({ detail, message: upstreamMessage, title }) => | |
| 113 | + firstNonEmpty(detail, upstreamMessage, title), | |
| 114 | + ) | |
| 115 | + .find((message) => message !== undefined); | |
| 116 | + const message = firstNonEmpty( | |
| 117 | + nested?.error.message, | |
| 118 | + messageBody?.message, | |
| 119 | + errorMessageBody?.errorMessage, | |
| 120 | + arrayMessage, | |
| 121 | + descriptionBody?.detail, | |
| 122 | + descriptionBody?.title, | |
| 123 | + descriptionBody?.description, | |
| 124 | + ); | |
| 125 | + if (message !== undefined) return message; | |
| 126 | + if (body !== null && typeof body === "object") { | |
| 127 | + return clampedStringify(body); | |
| 128 | + } | |
| 129 | + return `Upstream returned HTTP ${status}`; | |
| 130 | +}; | |
| 131 | + | |
| 60 | 132 | export type HeaderValue = HeaderValueValue; |
| 61 | 133 | export type ConfiguredHeaderValue = ConfiguredHeaderValueValue; |
| 62 | 134 | export type OpenApiHeaderInput = HeaderValue | ConfiguredHeaderValue; |
| @@ -1193,7 +1265,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1130 unmodified lines | ||
| 1193 | 1265 | name: "previewSpec", |
| 1194 | 1266 | description: "Preview an OpenAPI document before adding it as a source", |
| 1195 | 1267 | inputSchema: PreviewSpecInputStandardSchema, |
| 1196 | − execute: (input) => self.previewSpec(input), | |
| 1268 | + execute: (input) => Effect.map(self.previewSpec(input), ToolResult.ok), | |
| 1197 | 1269 | }), |
| 1198 | 1270 | tool({ |
| 1199 | 1271 | name: "addSource", |
| @@ -1204,7 +1276,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 4 unmodified lines | ||
| 1204 | 1276 | }, |
| 1205 | 1277 | inputSchema: AddSourceInputStandardSchema, |
| 1206 | 1278 | outputSchema: AddSourceOutputStandardSchema, |
| 1207 | − execute: (input) => self.addSpec(input), | |
| 1279 | + execute: (input) => Effect.map(self.addSpec(input), ToolResult.ok), | |
| 1208 | 1280 | }), |
| 1209 | 1281 | ], |
| 1210 | 1282 | }, |
| @@ -1283,7 +1355,20 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 72 unmodified lines | ||
| 1283 | 1355 | httpClientLayer, |
| 1284 | 1356 | ); |
| 1285 | 1357 | |
| 1286 | − return result; | |
| 1358 | + const ok = result.status >= 200 && result.status < 300; | |
| 1359 | + if (!ok) { | |
| 1360 | + return ToolResult.fail({ | |
| 1361 | + code: "upstream_http_error", | |
| 1362 | + status: result.status, | |
| 1363 | + message: extractUpstreamMessage(result.error, result.status), | |
| 1364 | + details: result.error, | |
| 1365 | + }); | |
| 1366 | + } | |
| 1367 | + return ToolResult.ok({ | |
| 1368 | + status: result.status, | |
| 1369 | + headers: result.headers, | |
| 1370 | + data: result.data, | |
| 1371 | + }); | |
| 1287 | 1372 | }), |
| 1288 | 1373 | |
| 1289 | 1374 | resolveAnnotations: ({ ctx, sourceId, toolRows }) => |
| @@ -37,6 +37,7 @@ import { · 36 unmodified lines | ||
| 37 | 37 | makeOpenApiTestSourceConfig, |
| 38 | 38 | type OpenApiTestServerShape, |
| 39 | 39 | serveOpenApiHttpApiTestServer, |
| 40 | + unwrapInvocation, | |
| 40 | 41 | } from "../testing"; |
| 41 | 42 | |
| 42 | 43 | import { openApiPlugin } from "./plugin"; |
| @@ -303,10 +304,10 @@ describe("OpenAPI upstream failure modes", () => { · 260 unmodified lines | ||
| 303 | 304 | const { baseUrl } = yield* slowServer; |
| 304 | 305 | const executor = yield* buildExecutor(baseUrl); |
| 305 | 306 | |
| 306 | − const result = yield* executor.tools.invoke("f.things.listThings", {}, autoApprove); | |
| 307 | − // Empty array via .data envelope or directly — accept either shape. | |
| 308 | − const data = (result as { data?: unknown }).data ?? result; | |
| 309 | − expect(data).toEqual([]); | |
| 307 | + const result = unwrapInvocation( | |
| 308 | + yield* executor.tools.invoke("f.things.listThings", {}, autoApprove), | |
| 309 | + ); | |
| 310 | + expect(result.data).toEqual([]); | |
| 310 | 311 | }), |
| 311 | 312 | ); |
| 312 | 313 | }); |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { Context, Data, Effect, Layer, Predicate, Ref, Schema, Scope } from "effect"; | |
| 1 | +import { Context, Data, Effect, Layer, Option, Predicate, Ref, Schema, Scope } from "effect"; | |
| 2 | 2 | import { |
| 3 | 3 | HttpClient, |
| 4 | 4 | HttpRouter, |
| @@ -13,7 +13,7 @@ import { · 8 unmodified lines | ||
| 13 | 13 | OpenApi, |
| 14 | 14 | } from "effect/unstable/httpapi"; |
| 15 | 15 | import { OAuthTestServer, serveTestHttpServerLayer } from "@executor-js/sdk/testing"; |
| 16 | −import type { ScopeId } from "@executor-js/sdk/core"; | |
| 16 | +import { isToolResult, type ScopeId } from "@executor-js/sdk/core"; | |
| 17 | 17 | import type { OpenApiPluginExtension, OpenApiSpecConfig } from "../sdk/plugin"; |
| 18 | 18 | |
| 19 | 19 | export class OpenApiTestServerAddressError extends Data.TaggedError( |
| @@ -618,3 +618,55 @@ export const TestLayers = { · 598 unmodified lines | ||
| 618 | 618 | echo: OpenApiEchoTestServer.layer, |
| 619 | 619 | echoWithOAuth: OpenApiEchoTestServer.layerWithOAuth, |
| 620 | 620 | }; |
| 621 | + | |
| 622 | +const OpenApiTransportEnvelope = Schema.Struct({ | |
| 623 | + status: Schema.Number, | |
| 624 | + headers: Schema.Record(Schema.String, Schema.String), | |
| 625 | + data: Schema.Unknown, | |
| 626 | +}); | |
| 627 | + | |
| 628 | +const decodeOpenApiTransportEnvelope = Schema.decodeUnknownOption(OpenApiTransportEnvelope); | |
| 629 | + | |
| 630 | +export interface OpenApiInvocationResult<TData = Record<string, unknown> | unknown[] | null> { | |
| 631 | + readonly status: number | null; | |
| 632 | + readonly headers: Record<string, string> | null; | |
| 633 | + readonly data: TData; | |
| 634 | + readonly error: unknown; | |
| 635 | +} | |
| 636 | + | |
| 637 | +export const unwrapInvocation = <TData = Record<string, unknown> | null>( | |
| 638 | + raw: unknown, | |
| 639 | +): OpenApiInvocationResult<TData> => { | |
| 640 | + if (!isToolResult(raw)) { | |
| 641 | + return { | |
| 642 | + status: null, | |
| 643 | + headers: null, | |
| 644 | + data: raw as TData, | |
| 645 | + error: null, | |
| 646 | + }; | |
| 647 | + } | |
| 648 | + if (raw.ok) { | |
| 649 | + const inner = raw.data; | |
| 650 | + const wrapped = Option.getOrUndefined(decodeOpenApiTransportEnvelope(inner)); | |
| 651 | + if (wrapped !== undefined) { | |
| 652 | + return { | |
| 653 | + status: wrapped.status, | |
| 654 | + headers: wrapped.headers, | |
| 655 | + data: wrapped.data as TData, | |
| 656 | + error: null, | |
| 657 | + }; | |
| 658 | + } | |
| 659 | + return { | |
| 660 | + status: null, | |
| 661 | + headers: null, | |
| 662 | + data: inner as TData, | |
| 663 | + error: null, | |
| 664 | + }; | |
| 665 | + } | |
| 666 | + return { | |
| 667 | + status: raw.error.status ?? null, | |
| 668 | + headers: null, | |
| 669 | + data: null as TData, | |
| 670 | + error: raw.error.details ?? raw.error, | |
| 671 | + }; | |
| 672 | +}; | |
| @@ -256,7 +256,7 @@ describe("WorkOS Vault secret provider", () => { · 255 unmodified lines | ||
| 256 | 256 | const client = makeFakeClient({ rejectReadNamesLongerThan: 80 }); |
| 257 | 257 | const executor = yield* makeExecutor(client); |
| 258 | 258 | const longSecretId = SecretId.make( |
| 259 | − "openapi-oauth-dealcloud-api-oauth2-user-org-user-01kp6xm1zpvqvtpj77f0yv4eax.access_token", | |
| 259 | + "openapi-oauth-example-api-oauth2-user-org-user-01kp6xm1zpvqvtpj77f0yv4eax.access_token", | |
| 260 | 260 | ); |
| 261 | 261 | |
| 262 | 262 | yield* executor.secrets.set( |