History

Route tool defects opaquely; remove legacy envelope shim (#826)

* 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

Rhys Sullivan committed 6 months ago

Commit 278ca7c1 parent 8d986ff35 files · +1322 −340Browse files

Showing the changes to packages/plugins (19 of 35 files). Show all

Unified · Split

19 files
  1. packages/plugins/google-discovery/src/sdk/option-json.test.ts +4 −4
  2. packages/plugins/google-discovery/src/sdk/plugin.test.ts +3 −3
  3. packages/plugins/google-discovery/src/sdk/plugin.ts +85 −5
  4. packages/plugins/graphql/src/sdk/plugin.test.ts +48 −9
  5. packages/plugins/graphql/src/sdk/plugin.ts +37 −2
  6. packages/plugins/mcp/src/react/AddMcpSource.tsx +11 −16
  7. packages/plugins/mcp/src/sdk/elicitation.test.ts +30 −3
  8. packages/plugins/mcp/src/sdk/per-user-auth-isolation.test.ts +6 −3
  9. packages/plugins/mcp/src/sdk/plugin.ts +31 −1
  10. packages/plugins/mcp/src/testing/server.ts +17 −0
  11. packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts +10 −13
  12. packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts +49 −60
  13. packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts +29 −24
  14. packages/plugins/openapi/src/sdk/oauth-refresh.test.ts +9 −12
  15. packages/plugins/openapi/src/sdk/plugin.test.ts +38 −39
  16. packages/plugins/openapi/src/sdk/plugin.ts +88 −3
  17. packages/plugins/openapi/src/sdk/upstream-failures.test.ts +5 −4
  18. packages/plugins/openapi/src/testing/index.ts +54 −2
  19. packages/plugins/workos-vault/src/sdk/secret-store.test.ts +1 −1
packages/plugins/google-discovery/src/sdk/option-json.test.tsrenamed from packages/plugins/google-discovery/src/sdk/option-json-repro.test.ts+4−4
@@ -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:
22 // 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:
44 //
55 // 1. Schema.encodeEffect(Inner)(value) → encoded JS shape
66 // 2. Schema.encodeEffect(UnknownFromJsonString) → JSON string
@@ -13,7 +13,7 @@ · 6 unmodified lines
1313 // step 2's JSON-stringify (driven by Effect, not us) flattens the Option
1414 // to {_id,_tag,value}, and step 5 rejects the shape.
1515 //
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
1717
1818 import { describe, expect, it } from "@effect/vitest";
1919 import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
@@ -28,7 +28,7 @@ const fixed = { description: Option.some("hello") }; · 8 unmodified lines
2828 const withTmpFile = <A, E>(fn: (path: string) => Effect.Effect<A, E, FileSystem.FileSystem>) =>
2929 Effect.gen(function* () {
3030 const fs = yield* FileSystem.FileSystem;
31− const dir = yield* fs.makeTempDirectoryScoped({ prefix: "option-repro-" });
31+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "option-json-" });
3232 return yield* fn(`${dir}/binding.json`);
3333 }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer));
3434
packages/plugins/google-discovery/src/sdk/plugin.test.ts+3−3
@@ -430,10 +430,10 @@ describe("Google Discovery plugin", () => { · 429 unmodified lines
430430 "drive.files.get",
431431 { fileId: "123", fields: "id,name", prettyPrint: true },
432432 autoApprove,
433− )) as { data: unknown; error: unknown };
433+ )) as { readonly ok: true; readonly data: { status: number; data: unknown } };
434434
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" });
437437
438438 const apiRequest = handle.requests.find((request) =>
439439 request.url.startsWith("/drive/v3/files/123"),
packages/plugins/google-discovery/src/sdk/plugin.ts+85−5
@@ -3,6 +3,7 @@ import { Effect, Option, Predicate, Schema } from "effect"; · 2 unmodified lines
33 import {
44 ScopeId,
55 SourceDetectionResult,
6+ ToolResult,
67 Usage,
78 definePlugin,
89 resolveSecretBackedMap,
@@ -29,6 +30,69 @@ import type { · 20 unmodified lines
2930 } from "./types";
3031 import { GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema } from "./types";
3132
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+
3296 // ---------------------------------------------------------------------------
3397 // Public input / output shapes
3498 // ---------------------------------------------------------------------------
@@ -381,11 +445,27 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 346 unmodified lines
381445 extension: makeGoogleDiscoveryPluginExtension,
382446
383447 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+ });
389469 }),
390470
391471 resolveAnnotations: ({ ctx, sourceId, toolRows }) =>
packages/plugins/graphql/src/sdk/plugin.test.ts+48−9
@@ -1,5 +1,6 @@
11 import { describe, it, expect } from "@effect/vitest";
22 import { Effect, Predicate } from "effect";
3+import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
34
45 import {
56 ConnectionId,
@@ -13,7 +14,7 @@ import { · 7 unmodified lines
1314 SecretId,
1415 TokenMaterial,
1516 } from "@executor-js/sdk";
16−import { makeTestConfig } from "@executor-js/sdk/testing";
17+import { makeTestConfig, serveTestHttpApp } from "@executor-js/sdk/testing";
1718 import { memorySecretsPlugin } from "@executor-js/sdk/testing";
1819
1920 import { graphqlPlugin } from "./plugin";
@@ -196,9 +197,8 @@ describe("graphqlPlugin real protocol server", () => { · 176 unmodified lines
196197 });
197198
198199 expect(result).toEqual({
199− status: 200,
200+ ok: true,
200201 data: { hello: "Hello Ada" },
201− errors: null,
202202 });
203203
204204 const requests = yield* server.requests;
@@ -209,6 +209,46 @@ describe("graphqlPlugin real protocol server", () => { · 4 unmodified lines
209209 }),
210210 );
211211
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+
212252 it.effect("invokes OAuth-backed sources with a bearer token", () =>
213253 Effect.gen(function* () {
214254 const server = yield* serveGreetingServer;
@@ -251,9 +291,8 @@ describe("graphqlPlugin real protocol server", () => { · 36 unmodified lines
251291 });
252292
253293 expect(result).toEqual({
254− status: 200,
294+ ok: true,
255295 data: { hello: "Hello Ada" },
256− errors: null,
257296 });
258297
259298 const requests = yield* server.requests;
@@ -425,7 +464,7 @@ describe("graphqlPlugin", () => { · 165 unmodified lines
425464 },
426465 { onElicitation: "accept-all" },
427466 );
428− expect(result).toEqual({ toolCount: 2, namespace: "via_static" });
467+ expect(result).toEqual({ ok: true, data: { toolCount: 2, namespace: "via_static" } });
429468 expect(yield* executor.graphql.getSource("via_static", String(userScope))).toBeNull();
430469 expect((yield* executor.graphql.getSource("via_static", String(orgScope)))?.scope).toBe(
431470 orgScope,
@@ -729,7 +768,7 @@ describe("graphqlPlugin", () => { · 297 unmodified lines
729768 });
730769
731770 expect(result).toMatchObject({
732− status: 200,
771+ ok: true,
733772 data: { hello: "Hello Ada" },
734773 });
735774 const requests = yield* server.requests;
@@ -843,7 +882,7 @@ describe("graphqlPlugin", () => { · 107 unmodified lines
843882 });
844883
845884 expect(result).toMatchObject({
846− status: 200,
885+ ok: true,
847886 data: { hello: "Hello Ada" },
848887 });
849888 const requests = yield* server.requests;
@@ -915,7 +954,7 @@ describe("graphqlPlugin", () => { · 65 unmodified lines
915954 });
916955
917956 expect(result).toMatchObject({
918− status: 200,
957+ ok: true,
919958 data: { hello: "Hello Ada" },
920959 });
921960 const requests = yield* server.requests;
packages/plugins/graphql/src/sdk/plugin.ts+37−2
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
1212 SecretId,
1313 SourceDetectionResult,
1414 StorageError,
15+ ToolResult,
1516 type PluginCtx,
1617 type StorageFailure,
1718 type ToolAnnotations,
@@ -65,6 +66,19 @@ import { · 47 unmodified lines
6566 // Plugin config
6667 // ---------------------------------------------------------------------------
6768
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+
6882 export type HeaderValue = HeaderValueValue;
6983 export type GraphqlCredentialValue = ConfiguredGraphqlCredentialValue;
7084
@@ -994,7 +1008,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 923 unmodified lines
9941008 },
9951009 inputSchema: StaticAddSourceInputStandardSchema,
9961010 outputSchema: StaticAddSourceOutputStandardSchema,
997− execute: (input) => self.addSource(input),
1011+ execute: (input) => Effect.map(self.addSource(input), ToolResult.ok),
9981012 }),
9991013 ],
10001014 },
@@ -1057,7 +1071,28 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 56 unmodified lines
10571071 httpClientLayer,
10581072 );
10591073
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);
10611096 }),
10621097
10631098 resolveAnnotations: ({ ctx, sourceId, toolRows }) =>
packages/plugins/mcp/src/react/AddMcpSource.tsx+11−16
@@ -55,6 +55,13 @@ import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types · 54 unmodified lines
5555
5656 const ErrorMessage = Schema.Struct({ message: Schema.String });
5757 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+};
5865
5966 const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
6067 Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {
@@ -525,22 +532,10 @@ export default function AddMcpSource(props: { · 464 unmodified lines
525532 const inner = value.slice(1, -1);
526533 if (quote === "'") return inner;
527534
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+ );
544539 };
545540
546541 const parseStdioEnv = (raw: string): Record<string, string> | undefined => {
packages/plugins/mcp/src/sdk/elicitation.test.ts+30−3
@@ -69,7 +69,8 @@ describe("MCP elicitation (end-to-end)", () => { · 68 unmodified lines
6969 const result = yield* executor.tools.invoke(gatedEcho!.id, { value: "hello" }, options);
7070
7171 expect(result).toMatchObject({
72− content: [{ type: "text", text: "approved:hello" }],
72+ ok: true,
73+ data: { content: [{ type: "text", text: "approved:hello" }] },
7374 });
7475 // At least one elicitation should be the MCP server's form
7576 expect(elicitationMessages.length).toBeGreaterThanOrEqual(1);
@@ -95,7 +96,8 @@ describe("MCP elicitation (end-to-end)", () => { · 19 unmodified lines
9596 );
9697
9798 expect(result).toMatchObject({
98− content: [{ type: "text", text: "denied:nope" }],
99+ ok: true,
100+ data: { content: [{ type: "text", text: "denied:nope" }] },
99101 });
100102 }),
101103 );
@@ -114,7 +116,32 @@ describe("MCP elicitation (end-to-end)", () => { · 12 unmodified lines
114116 );
115117
116118 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+ },
118145 });
119146 }),
120147 );
packages/plugins/mcp/src/sdk/per-user-auth-isolation.test.ts+6−3
@@ -115,7 +115,8 @@ describe("per-user MCP auth isolation", () => { · 114 unmodified lines
115115 { onElicitation: "accept-all" },
116116 );
117117 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" }] },
119120 });
120121 expect(
121122 (yield* server.requests)
@@ -187,7 +188,8 @@ describe("per-user MCP auth isolation", () => { · 65 unmodified lines
187188 { onElicitation: "accept-all" },
188189 );
189190 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" }] },
191193 });
192194 expect(
193195 (yield* server.requests)
@@ -267,7 +269,8 @@ describe("per-user MCP auth isolation", () => { · 73 unmodified lines
267269 );
268270
269271 expect(result).toMatchObject({
270− content: [{ type: "text", text: "ok:org-header" }],
272+ ok: true,
273+ data: { content: [{ type: "text", text: "ok:org-header" }] },
271274 });
272275 const invokeRequests = (yield* server.requests).slice(beforeInvoke);
273276 expect(
packages/plugins/mcp/src/sdk/plugin.ts+31−1
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 Predicate,
99 Result,
1010 Scope,
11+ Schema,
1112 ScopedCache,
1213 } from "effect";
1314 import type { HttpClient } from "effect/unstable/http";
@@ -22,6 +23,7 @@ import { · 8 unmodified lines
2223 ScopeId,
2324 SecretId,
2425 SourceDetectionResult,
26+ ToolResult,
2527 definePlugin,
2628 resolveSecretBackedMap as resolveSharedSecretBackedMap,
2729 type PluginCtx,
@@ -193,6 +195,24 @@ const toBinding = (entry: McpToolManifestEntry): McpToolBinding => · 165 unmodified lines
193195 });
194196
195197 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+};
196216
197217 /** Match `token` as a separator-bounded run inside a URL hostname or path,
198218 * 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
17261746 });
17271747 }
17281748
1729− return yield* invokeMcpTool({
1749+ const raw = yield* invokeMcpTool({
17301750 toolId: toolRow.id,
17311751 toolName: entry.binding.toolName,
17321752 args,
@@ -1764,6 +1784,16 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 31 unmodified lines
17641784 pendingConnectors: runtime.pendingConnectors,
17651785 elicit,
17661786 });
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);
17671797 }).pipe(
17681798 Effect.withSpan("mcp.plugin.invoke_tool", {
17691799 attributes: {
packages/plugins/mcp/src/testing/server.ts+17−0
@@ -375,6 +375,23 @@ export const makeElicitationMcpServer = () => { · 374 unmodified lines
375375 }),
376376 );
377377
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+
378395 return server;
379396 };
380397
packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts+10−13
@@ -26,6 +26,7 @@ import { makeTestConfig, serveOAuthTestServer } from "@executor-js/sdk/testing"; · 25 unmodified lines
2626 import {
2727 addOpenApiTestSource,
2828 serveOpenApiHttpApiTestServer,
29+ unwrapInvocation,
2930 } from "@executor-js/plugin-openapi/testing";
3031
3132 import { openApiPlugin } from "./plugin";
@@ -226,25 +227,21 @@ describe("OpenAPI client_credentials OAuth", () => { · 194 unmodified lines
226227 );
227228 // Invoking the tool injects the freshly-minted bearer via
228229 // 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+ );
237233 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, "");
239236 expect(bearer).toBeDefined();
240237 expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true);
241238
242239 // The connection lives at the innermost (user) scope, which
243240 // 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.
248245 const userConnections = yield* userExec.connections.list();
249246 const connection = userConnections.find((c) => c.id === completedConnection.connectionId);
250247 expect(connection).toBeDefined();
packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts+49−60
@@ -38,6 +38,7 @@ import { · 37 unmodified lines
3838 addOpenApiTestSource,
3939 makeOpenApiTestSourceConfig,
4040 serveOpenApiHttpApiTestServer,
41+ unwrapInvocation,
4142 } from "@executor-js/plugin-openapi/testing";
4243
4344 import { openApiPlugin } from "./plugin";
@@ -277,25 +278,21 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 233 unmodified lines
277278 // yields her token; bob's scope yields his. Same source, same
278279 // tool, different injected bearer.
279280 // -------------------------------------------------------------
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+ );
288284 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");
291288
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+ );
296292 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");
299296
300297 // -------------------------------------------------------------
301298 // 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
450447 }),
451448 );
452449
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+ );
458453 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+ );
460457
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+ );
465461 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");
467463 }),
468464 );
469465
@@ -558,13 +554,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 88 unmodified lines
558554 }),
559555 );
560556
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+ );
566560 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");
568562
569563 yield* aliceExec.secrets.set(
570564 SetSecretInput.make({
@@ -587,13 +581,13 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 16 unmodified lines
587581 }),
588582 );
589583
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+ );
595587 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+ );
597591
598592 yield* aliceExec.openapi.removeSourceBinding(
599593 "vercel",
@@ -602,13 +596,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 2 unmodified lines
602596 String(aliceScope.id),
603597 );
604598
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+ );
610602 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");
612604
613605 yield* aliceExec.openapi.setSourceBinding(
614606 OpenApiSourceBindingInput.make({
@@ -812,12 +804,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 197 unmodified lines
812804 }),
813805 );
814806
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+ );
819810 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");
821812 }),
822813 );
823814
@@ -914,13 +905,12 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 90 unmodified lines
914905 }),
915906 );
916907
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+ );
921911
922912 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");
924914 }),
925915 );
926916
@@ -1013,13 +1003,12 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 86 unmodified lines
10131003 }),
10141004 );
10151005
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+ );
10201009
10211010 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");
10231012 }),
10241013 );
10251014 });
packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts+29−24
@@ -28,6 +28,7 @@ import { makeTestConfig, serveOAuthTestServer } from "@executor-js/sdk/testing"; · 27 unmodified lines
2828 import {
2929 addOpenApiTestSource,
3030 serveOpenApiHttpApiTestServer,
31+ unwrapInvocation,
3132 } from "@executor-js/plugin-openapi/testing";
3233
3334 import { openApiPlugin } from "./plugin";
@@ -307,23 +308,25 @@ describe("OpenAPI multi-scope OAuth", () => { · 273 unmodified lines
307308 // 4. Invoke through each exec — Authorization must carry that
308309 // user's token.
309310 // -------------------------------------------------------------
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+ );
315314 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+ );
317319 expect(aliceBearer).toBeDefined();
318320 expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true);
319321
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+ );
325325 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+ );
327330 expect(bobBearer).toBeDefined();
328331 expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true);
329332 expect(bobBearer).not.toBe(aliceBearer);
@@ -610,23 +613,25 @@ describe("OpenAPI multi-scope OAuth", () => { · 280 unmodified lines
610613 // (4) Each user's invocation resolves their OWN row and gets
611614 // their OWN token — not whatever the last signer happened to
612615 // 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+ );
618619 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+ );
620624 expect(aliceBearer).toBeDefined();
621625 expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true);
622626
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+ );
628630 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+ );
630635 expect(bobBearer).toBeDefined();
631636 expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true);
632637 expect(bobBearer).not.toBe(aliceBearer);
packages/plugins/openapi/src/sdk/oauth-refresh.test.ts+9−12
@@ -35,6 +35,7 @@ import { makeTestConfig, serveOAuthTestServer } from "@executor-js/sdk/testing"; · 34 unmodified lines
3535 import {
3636 addOpenApiTestSource,
3737 serveOpenApiHttpApiTestServer,
38+ unwrapInvocation,
3839 } from "@executor-js/plugin-openapi/testing";
3940
4041 import { openApiPlugin } from "./plugin";
@@ -253,17 +254,16 @@ describe("OpenAPI oauth refresh", () => { · 212 unmodified lines
253254 });
254255 yield* bindOAuthConnection(executor, scopeId, "conn-refresh-ok", auth);
255256
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+ );
261260
262261 expect(result.error).toBeNull();
262+ const data = result.data as EchoHeaders | null;
263263 // Proves the refresh landed: invoke carried the fresh token,
264264 // 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, "");
267267 expect(bearer).toBeDefined();
268268 expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true);
269269 const calls = refreshTokenRequests(yield* oauth.requests);
@@ -313,12 +313,9 @@ describe("OpenAPI oauth refresh", () => { · 43 unmodified lines
313313 );
314314
315315 for (const r of invokes) {
316− const res = r as {
317− data: { authorization?: string } | null;
318− error: unknown;
319− };
316+ const res = unwrapInvocation(r);
320317 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, "");
322319 expect(bearer).toBeDefined();
323320 expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true);
324321 }
packages/plugins/openapi/src/sdk/plugin.test.ts+38−39
@@ -25,6 +25,7 @@ import { · 24 unmodified lines
2525 addOpenApiTestSource,
2626 makeOpenApiHttpApiTestSourceConfig,
2727 serveOpenApiHttpApiTestServer,
28+ unwrapInvocation,
2829 } from "../testing";
2930
3031 const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
@@ -267,13 +268,15 @@ describe("OpenAPI Plugin", () => { · 236 unmodified lines
267268 }),
268269 );
269270
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 };
275278
276− expect(result.operationCount).toBeGreaterThanOrEqual(2);
279+ expect(preview.operationCount).toBeGreaterThanOrEqual(2);
277280 }),
278281 );
279282
@@ -316,11 +319,13 @@ describe("OpenAPI Plugin", () => { · 36 unmodified lines
316319 }),
317320 );
318321
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 };
324329
325330 expect(result).toEqual({ sourceId: "runtime", toolCount: 4 });
326331 expect(yield* executor.openapi.getSource("runtime", String(userScope))).toBeNull();
@@ -557,17 +562,12 @@ describe("OpenAPI Plugin", () => { · 230 unmodified lines
557562 },
558563 });
559564
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+ );
568568
569569 expect(result.error).toBeNull();
570− const data = result.data!;
570+ const data = result.data as { authorization?: string; "x-static"?: string };
571571 expect(data.authorization).toBe("Bearer secret-value-123");
572572 expect(data["x-static"]).toBe("hello");
573573 }),
@@ -751,10 +751,9 @@ describe("OpenAPI Plugin", () => { · 177 unmodified lines
751751 namespace: "test",
752752 });
753753
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+ );
758757 expect(result.error).toBeNull();
759758 expect(result.data).toEqual(ITEMS);
760759 }),
@@ -780,11 +779,9 @@ describe("OpenAPI Plugin", () => { · 19 unmodified lines
780779 namespace: "test",
781780 });
782781
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+ );
788785 expect(result.error).toBeNull();
789786 expect(result.data).toEqual({ id: 2, name: "Gadget" });
790787 }),
@@ -810,16 +807,18 @@ describe("OpenAPI Plugin", () => { · 19 unmodified lines
810807 namespace: "records",
811808 });
812809
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+ );
823822
824823 expect(result.data).toBeNull();
825824 expect(result.error).toEqual(
packages/plugins/openapi/src/sdk/plugin.ts+88−3
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
77 SecretId,
88 SourceDetectionResult,
99 StorageError,
10+ ToolResult,
1011 definePlugin,
1112 tool,
1213 resolveSecretBackedMap,
@@ -57,6 +58,77 @@ import { · 44 unmodified lines
5758 // Plugin config
5859 // ---------------------------------------------------------------------------
5960
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+
60132 export type HeaderValue = HeaderValueValue;
61133 export type ConfiguredHeaderValue = ConfiguredHeaderValueValue;
62134 export type OpenApiHeaderInput = HeaderValue | ConfiguredHeaderValue;
@@ -1193,7 +1265,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1130 unmodified lines
11931265 name: "previewSpec",
11941266 description: "Preview an OpenAPI document before adding it as a source",
11951267 inputSchema: PreviewSpecInputStandardSchema,
1196− execute: (input) => self.previewSpec(input),
1268+ execute: (input) => Effect.map(self.previewSpec(input), ToolResult.ok),
11971269 }),
11981270 tool({
11991271 name: "addSource",
@@ -1204,7 +1276,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 4 unmodified lines
12041276 },
12051277 inputSchema: AddSourceInputStandardSchema,
12061278 outputSchema: AddSourceOutputStandardSchema,
1207− execute: (input) => self.addSpec(input),
1279+ execute: (input) => Effect.map(self.addSpec(input), ToolResult.ok),
12081280 }),
12091281 ],
12101282 },
@@ -1283,7 +1355,20 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 72 unmodified lines
12831355 httpClientLayer,
12841356 );
12851357
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+ });
12871372 }),
12881373
12891374 resolveAnnotations: ({ ctx, sourceId, toolRows }) =>
packages/plugins/openapi/src/sdk/upstream-failures.test.ts+5−4
@@ -37,6 +37,7 @@ import { · 36 unmodified lines
3737 makeOpenApiTestSourceConfig,
3838 type OpenApiTestServerShape,
3939 serveOpenApiHttpApiTestServer,
40+ unwrapInvocation,
4041 } from "../testing";
4142
4243 import { openApiPlugin } from "./plugin";
@@ -303,10 +304,10 @@ describe("OpenAPI upstream failure modes", () => { · 260 unmodified lines
303304 const { baseUrl } = yield* slowServer;
304305 const executor = yield* buildExecutor(baseUrl);
305306
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([]);
310311 }),
311312 );
312313 });
packages/plugins/openapi/src/testing/index.ts+54−2
@@ -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";
22 import {
33 HttpClient,
44 HttpRouter,
@@ -13,7 +13,7 @@ import { · 8 unmodified lines
1313 OpenApi,
1414 } from "effect/unstable/httpapi";
1515 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";
1717 import type { OpenApiPluginExtension, OpenApiSpecConfig } from "../sdk/plugin";
1818
1919 export class OpenApiTestServerAddressError extends Data.TaggedError(
@@ -618,3 +618,55 @@ export const TestLayers = { · 598 unmodified lines
618618 echo: OpenApiEchoTestServer.layer,
619619 echoWithOAuth: OpenApiEchoTestServer.layerWithOAuth,
620620 };
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+};
packages/plugins/workos-vault/src/sdk/secret-store.test.ts+1−1
@@ -256,7 +256,7 @@ describe("WorkOS Vault secret provider", () => { · 255 unmodified lines
256256 const client = makeFakeClient({ rejectReadNamesLongerThan: 80 });
257257 const executor = yield* makeExecutor(client);
258258 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",
260260 );
261261
262262 yield* executor.secrets.set(

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t