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:
2// no JSON.parse, no JSON.stringify, no node:fs on our side. We split the2// 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// 1. Schema.encodeEffect(Inner)(value) → encoded JS shape5// 1. Schema.encodeEffect(Inner)(value) → encoded JS shape
6// 2. Schema.encodeEffect(UnknownFromJsonString) → JSON string6// 2. Schema.encodeEffect(UnknownFromJsonString) → JSON string
@@ -13,7 +13,7 @@ · 6 unmodified lines
13// step 2's JSON-stringify (driven by Effect, not us) flattens the Option13// step 2's JSON-stringify (driven by Effect, not us) flattens the Option
14// to {_id,_tag,value}, and step 5 rejects the shape.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.ts16// Run: vitest run packages/plugins/google-discovery/src/sdk/option-json.test.ts
17 17
18import { describe, expect, it } from "@effect/vitest";18import { describe, expect, it } from "@effect/vitest";
19import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";19import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
@@ -28,7 +28,7 @@ const fixed = { description: Option.some("hello") }; · 8 unmodified lines
28const withTmpFile = <A, E>(fn: (path: string) => Effect.Effect<A, E, FileSystem.FileSystem>) =>28const withTmpFile = <A, E>(fn: (path: string) => Effect.Effect<A, E, FileSystem.FileSystem>) =>
29 Effect.gen(function* () {29 Effect.gen(function* () {
30 const fs = yield* FileSystem.FileSystem;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 return yield* fn(`${dir}/binding.json`);32 return yield* fn(`${dir}/binding.json`);
33 }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer));33 }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer));
34 34
packages/plugins/google-discovery/src/sdk/plugin.test.ts+3−3
@@ -430,10 +430,10 @@ describe("Google Discovery plugin", () => { · 429 unmodified lines
430 "drive.files.get",430 "drive.files.get",
431 { fileId: "123", fields: "id,name", prettyPrint: true },431 { fileId: "123", fields: "id,name", prettyPrint: true },
432 autoApprove,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();435 expect(invocation.ok).toBe(true);
436 expect(invocation.data).toEqual({ id: "123", name: "Quarterly Plan" });436 expect(invocation.data.data).toEqual({ id: "123", name: "Quarterly Plan" });
437 437
438 const apiRequest = handle.requests.find((request) =>438 const apiRequest = handle.requests.find((request) =>
439 request.url.startsWith("/drive/v3/files/123"),439 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
3import {3import {
4 ScopeId,4 ScopeId,
5 SourceDetectionResult,5 SourceDetectionResult,
6 ToolResult,
6 Usage,7 Usage,
7 definePlugin,8 definePlugin,
8 resolveSecretBackedMap,9 resolveSecretBackedMap,
@@ -29,6 +30,69 @@ import type { · 20 unmodified lines
29} from "./types";30} from "./types";
30import { GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema } from "./types";31import { GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema } from "./types";
31 32
33// ---------------------------------------------------------------------------
34// Upstream-error message extraction
35// ---------------------------------------------------------------------------
36
37const GOOGLE_BODY_CAP = 1024;
38const UpstreamMessageBody = Schema.Struct({ message: Schema.String });
39const UpstreamErrorMessageBody = Schema.Struct({ errorMessage: Schema.String });
40const UpstreamNestedErrorBody = Schema.Struct({ error: UpstreamMessageBody });
41const 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
51const decodeUpstreamMessageBody = Schema.decodeUnknownOption(UpstreamMessageBody);
52const decodeUpstreamErrorMessageBody = Schema.decodeUnknownOption(UpstreamErrorMessageBody);
53const decodeUpstreamNestedErrorBody = Schema.decodeUnknownOption(UpstreamNestedErrorBody);
54const decodeUpstreamErrorsArrayBody = Schema.decodeUnknownOption(UpstreamErrorsArrayBody);
55
56const 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
67const firstNonEmpty = (...values: readonly (string | undefined)[]): string | undefined =>
68 values.find((value) => value !== undefined && value.length > 0);
69
70const 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// Public input / output shapes97// Public input / output shapes
34// ---------------------------------------------------------------------------98// ---------------------------------------------------------------------------
@@ -381,11 +445,27 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 346 unmodified lines
381 extension: makeGoogleDiscoveryPluginExtension,445 extension: makeGoogleDiscoveryPluginExtension,
382 446
383 invokeTool: ({ ctx, toolRow, args }) =>447 invokeTool: ({ ctx, toolRow, args }) =>
384 invokeGoogleDiscoveryTool({448 Effect.gen(function* () {
385 ctx: ctx as PluginCtx<GoogleDiscoveryStore>,449 const result = yield* invokeGoogleDiscoveryTool({
386 toolId: toolRow.id,450 ctx: ctx as PluginCtx<GoogleDiscoveryStore>,
387 toolScope: decodeString(toolRow.scope_id),451 toolId: toolRow.id,
388 args,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 resolveAnnotations: ({ ctx, sourceId, toolRows }) =>471 resolveAnnotations: ({ ctx, sourceId, toolRows }) =>
packages/plugins/graphql/src/sdk/plugin.test.ts+48−9
@@ -1,5 +1,6 @@
1import { describe, it, expect } from "@effect/vitest";1import { describe, it, expect } from "@effect/vitest";
2import { Effect, Predicate } from "effect";2import { Effect, Predicate } from "effect";
3import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
3 4
4import {5import {
5 ConnectionId,6 ConnectionId,
@@ -13,7 +14,7 @@ import { · 7 unmodified lines
13 SecretId,14 SecretId,
14 TokenMaterial,15 TokenMaterial,
15} from "@executor-js/sdk";16} from "@executor-js/sdk";
16import { makeTestConfig } from "@executor-js/sdk/testing";17import { makeTestConfig, serveTestHttpApp } from "@executor-js/sdk/testing";
17import { memorySecretsPlugin } from "@executor-js/sdk/testing";18import { memorySecretsPlugin } from "@executor-js/sdk/testing";
18 19
19import { graphqlPlugin } from "./plugin";20import { graphqlPlugin } from "./plugin";
@@ -196,9 +197,8 @@ describe("graphqlPlugin real protocol server", () => { · 176 unmodified lines
196 });197 });
197 198
198 expect(result).toEqual({199 expect(result).toEqual({
199 status: 200,200 ok: true,
200 data: { hello: "Hello Ada" },201 data: { hello: "Hello Ada" },
201 errors: null,
202 });202 });
203 203
204 const requests = yield* server.requests;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 it.effect("invokes OAuth-backed sources with a bearer token", () =>252 it.effect("invokes OAuth-backed sources with a bearer token", () =>
213 Effect.gen(function* () {253 Effect.gen(function* () {
214 const server = yield* serveGreetingServer;254 const server = yield* serveGreetingServer;
@@ -251,9 +291,8 @@ describe("graphqlPlugin real protocol server", () => { · 36 unmodified lines
251 });291 });
252 292
253 expect(result).toEqual({293 expect(result).toEqual({
254 status: 200,294 ok: true,
255 data: { hello: "Hello Ada" },295 data: { hello: "Hello Ada" },
256 errors: null,
257 });296 });
258 297
259 const requests = yield* server.requests;298 const requests = yield* server.requests;
@@ -425,7 +464,7 @@ describe("graphqlPlugin", () => { · 165 unmodified lines
425 },464 },
426 { onElicitation: "accept-all" },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 expect(yield* executor.graphql.getSource("via_static", String(userScope))).toBeNull();468 expect(yield* executor.graphql.getSource("via_static", String(userScope))).toBeNull();
430 expect((yield* executor.graphql.getSource("via_static", String(orgScope)))?.scope).toBe(469 expect((yield* executor.graphql.getSource("via_static", String(orgScope)))?.scope).toBe(
431 orgScope,470 orgScope,
@@ -729,7 +768,7 @@ describe("graphqlPlugin", () => { · 297 unmodified lines
729 });768 });
730 769
731 expect(result).toMatchObject({770 expect(result).toMatchObject({
732 status: 200,771 ok: true,
733 data: { hello: "Hello Ada" },772 data: { hello: "Hello Ada" },
734 });773 });
735 const requests = yield* server.requests;774 const requests = yield* server.requests;
@@ -843,7 +882,7 @@ describe("graphqlPlugin", () => { · 107 unmodified lines
843 });882 });
844 883
845 expect(result).toMatchObject({884 expect(result).toMatchObject({
846 status: 200,885 ok: true,
847 data: { hello: "Hello Ada" },886 data: { hello: "Hello Ada" },
848 });887 });
849 const requests = yield* server.requests;888 const requests = yield* server.requests;
@@ -915,7 +954,7 @@ describe("graphqlPlugin", () => { · 65 unmodified lines
915 });954 });
916 955
917 expect(result).toMatchObject({956 expect(result).toMatchObject({
918 status: 200,957 ok: true,
919 data: { hello: "Hello Ada" },958 data: { hello: "Hello Ada" },
920 });959 });
921 const requests = yield* server.requests;960 const requests = yield* server.requests;
packages/plugins/graphql/src/sdk/plugin.ts+37−2
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
12 SecretId,12 SecretId,
13 SourceDetectionResult,13 SourceDetectionResult,
14 StorageError,14 StorageError,
15 ToolResult,
15 type PluginCtx,16 type PluginCtx,
16 type StorageFailure,17 type StorageFailure,
17 type ToolAnnotations,18 type ToolAnnotations,
@@ -65,6 +66,19 @@ import { · 47 unmodified lines
65// Plugin config66// Plugin config
66// ---------------------------------------------------------------------------67// ---------------------------------------------------------------------------
67 68
69const GraphqlErrorBody = Schema.Struct({ message: Schema.String });
70const GraphqlErrorsBody = Schema.Array(Schema.Unknown);
71const decodeGraphqlErrorBody = Schema.decodeUnknownOption(GraphqlErrorBody);
72const decodeGraphqlErrorsBody = Schema.decodeUnknownOption(GraphqlErrorsBody);
73
74const decodeGraphqlErrors = (errors: unknown): readonly unknown[] | undefined =>
75 Option.getOrUndefined(decodeGraphqlErrorsBody(errors));
76
77const 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
68export type HeaderValue = HeaderValueValue;82export type HeaderValue = HeaderValueValue;
69export type GraphqlCredentialValue = ConfiguredGraphqlCredentialValue;83export type GraphqlCredentialValue = ConfiguredGraphqlCredentialValue;
70 84
@@ -994,7 +1008,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 923 unmodified lines
994 },1008 },
995 inputSchema: StaticAddSourceInputStandardSchema,1009 inputSchema: StaticAddSourceInputStandardSchema,
996 outputSchema: StaticAddSourceOutputStandardSchema,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 httpClientLayer,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 resolveAnnotations: ({ ctx, sourceId, toolRows }) =>1098 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
55 55
56const ErrorMessage = Schema.Struct({ message: Schema.String });56const ErrorMessage = Schema.Struct({ message: Schema.String });
57const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);57const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
58const STDIO_ENV_ESCAPE_REPLACEMENTS: Readonly<Record<string, string>> = {
59 "\\": "\\",
60 n: "\n",
61 r: "\r",
62 t: "\t",
63 '"': '"',
64};
58 65
59const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>66const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
60 Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {67 Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {
@@ -525,22 +532,10 @@ export default function AddMcpSource(props: { · 464 unmodified lines
525 const inner = value.slice(1, -1);532 const inner = value.slice(1, -1);
526 if (quote === "'") return inner;533 if (quote === "'") return inner;
527 534
528 return inner.replace(/\\([\\nrt"])/g, (_, escaped: string) => {535 return inner.replace(
529 switch (escaped) {536 /\\([\\nrt"])/g,
530 case "\\":537 (_, escaped: string) => STDIO_ENV_ESCAPE_REPLACEMENTS[escaped] ?? escaped,
531 return "\\";538 );
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 });
544 };539 };
545 540
546 const parseStdioEnv = (raw: string): Record<string, string> | undefined => {541 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
69 const result = yield* executor.tools.invoke(gatedEcho!.id, { value: "hello" }, options);69 const result = yield* executor.tools.invoke(gatedEcho!.id, { value: "hello" }, options);
70 70
71 expect(result).toMatchObject({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 // At least one elicitation should be the MCP server's form75 // At least one elicitation should be the MCP server's form
75 expect(elicitationMessages.length).toBeGreaterThanOrEqual(1);76 expect(elicitationMessages.length).toBeGreaterThanOrEqual(1);
@@ -95,7 +96,8 @@ describe("MCP elicitation (end-to-end)", () => { · 19 unmodified lines
95 );96 );
96 97
97 expect(result).toMatchObject({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 expect(result).toMatchObject({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 );
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
115 { onElicitation: "accept-all" },115 { onElicitation: "accept-all" },
116 );116 );
117 expect(userAResult).toMatchObject({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 expect(121 expect(
121 (yield* server.requests)122 (yield* server.requests)
@@ -187,7 +188,8 @@ describe("per-user MCP auth isolation", () => { · 65 unmodified lines
187 { onElicitation: "accept-all" },188 { onElicitation: "accept-all" },
188 );189 );
189 expect(userAResult).toMatchObject({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 expect(194 expect(
193 (yield* server.requests)195 (yield* server.requests)
@@ -267,7 +269,8 @@ describe("per-user MCP auth isolation", () => { · 73 unmodified lines
267 );269 );
268 270
269 expect(result).toMatchObject({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 const invokeRequests = (yield* server.requests).slice(beforeInvoke);275 const invokeRequests = (yield* server.requests).slice(beforeInvoke);
273 expect(276 expect(
packages/plugins/mcp/src/sdk/plugin.ts+31−1
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
8 Predicate,8 Predicate,
9 Result,9 Result,
10 Scope,10 Scope,
11 Schema,
11 ScopedCache,12 ScopedCache,
12} from "effect";13} from "effect";
13import type { HttpClient } from "effect/unstable/http";14import type { HttpClient } from "effect/unstable/http";
@@ -22,6 +23,7 @@ import { · 8 unmodified lines
22 ScopeId,23 ScopeId,
23 SecretId,24 SecretId,
24 SourceDetectionResult,25 SourceDetectionResult,
26 ToolResult,
25 definePlugin,27 definePlugin,
26 resolveSecretBackedMap as resolveSharedSecretBackedMap,28 resolveSecretBackedMap as resolveSharedSecretBackedMap,
27 type PluginCtx,29 type PluginCtx,
@@ -193,6 +195,24 @@ const toBinding = (entry: McpToolManifestEntry): McpToolBinding => · 165 unmodified lines
193 });195 });
194 196
195const MCP_PLUGIN_ID = "mcp";197const MCP_PLUGIN_ID = "mcp";
198const McpTextContent = Schema.Struct({ type: Schema.Literal("text"), text: Schema.String });
199const McpToolCallEnvelope = Schema.Struct({
200 isError: Schema.optional(Schema.Boolean),
201 content: Schema.optional(Schema.Array(Schema.Unknown)),
202});
203
204const decodeMcpTextContent = Schema.decodeUnknownOption(McpTextContent);
205const decodeMcpToolCallEnvelope = Schema.decodeUnknownOption(McpToolCallEnvelope);
206
207const 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/** Match `token` as a separator-bounded run inside a URL hostname or path,217/** Match `token` as a separator-bounded run inside a URL hostname or path,
198 * used as a low-confidence detection hint when wire-shape detection fails.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 toolId: toolRow.id,1750 toolId: toolRow.id,
1731 toolName: entry.binding.toolName,1751 toolName: entry.binding.toolName,
1732 args,1752 args,
@@ -1764,6 +1784,16 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 31 unmodified lines
1764 pendingConnectors: runtime.pendingConnectors,1784 pendingConnectors: runtime.pendingConnectors,
1765 elicit,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 }).pipe(1797 }).pipe(
1768 Effect.withSpan("mcp.plugin.invoke_tool", {1798 Effect.withSpan("mcp.plugin.invoke_tool", {
1769 attributes: {1799 attributes: {
packages/plugins/mcp/src/testing/server.ts+17−0
@@ -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 return server;395 return server;
379};396};
380 397
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
26import {26import {
27 addOpenApiTestSource,27 addOpenApiTestSource,
28 serveOpenApiHttpApiTestServer,28 serveOpenApiHttpApiTestServer,
29 unwrapInvocation,
29} from "@executor-js/plugin-openapi/testing";30} from "@executor-js/plugin-openapi/testing";
30 31
31import { openApiPlugin } from "./plugin";32import { openApiPlugin } from "./plugin";
@@ -226,25 +227,21 @@ describe("OpenAPI client_credentials OAuth", () => { · 194 unmodified lines
226 );227 );
227 // Invoking the tool injects the freshly-minted bearer via228 // Invoking the tool injects the freshly-minted bearer via
228 // ctx.connections.accessToken.229 // ctx.connections.accessToken.
229 const result = (yield* userExec.tools.invoke(230 const result = unwrapInvocation(
230 "petstore.items.echoHeaders",231 yield* userExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),
231 {},232 );
232 autoApprove,
233 )) as {
234 data: { authorization?: string } | null;
235 error: unknown;
236 };
237 expect(result.error).toBeNull();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 expect(bearer).toBeDefined();236 expect(bearer).toBeDefined();
240 expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true);237 expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true);
241 238
242 // The connection lives at the innermost (user) scope, which239 // The connection lives at the innermost (user) scope, which
243 // preserves per-user credential resolution: if each user has240 // preserves per-user credential resolution: if each user has
244 // their own `dealcloud_client_id`/`dealcloud_client_secret`241 // their own OAuth client credentials shadowed at their user
245 // shadowed at their user scope, each user mints their own242 // scope, each user mints their own token. A single shared
246 // token. A single shared connection slot still lets every caller243 // connection slot still lets every caller reach the right
247 // reach the right physical row through scoped credential bindings.244 // physical row through scoped credential bindings.
248 const userConnections = yield* userExec.connections.list();245 const userConnections = yield* userExec.connections.list();
249 const connection = userConnections.find((c) => c.id === completedConnection.connectionId);246 const connection = userConnections.find((c) => c.id === completedConnection.connectionId);
250 expect(connection).toBeDefined();247 expect(connection).toBeDefined();
packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts+49−60
@@ -38,6 +38,7 @@ import { · 37 unmodified lines
38 addOpenApiTestSource,38 addOpenApiTestSource,
39 makeOpenApiTestSourceConfig,39 makeOpenApiTestSourceConfig,
40 serveOpenApiHttpApiTestServer,40 serveOpenApiHttpApiTestServer,
41 unwrapInvocation,
41} from "@executor-js/plugin-openapi/testing";42} from "@executor-js/plugin-openapi/testing";
42 43
43import { openApiPlugin } from "./plugin";44import { openApiPlugin } from "./plugin";
@@ -277,25 +278,21 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 233 unmodified lines
277 // yields her token; bob's scope yields his. Same source, same278 // yields her token; bob's scope yields his. Same source, same
278 // tool, different injected bearer.279 // tool, different injected bearer.
279 // -------------------------------------------------------------280 // -------------------------------------------------------------
280 const aliceResult = (yield* aliceExec.tools.invoke(281 const aliceResult = unwrapInvocation(
281 "vercel.projects.list",282 yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove),
282 {},283 );
283 autoApprove,
284 )) as {
285 data: { authorization?: string; token?: string } | null;
286 error: unknown;
287 };
288 expect(aliceResult.error).toBeNull();284 expect(aliceResult.error).toBeNull();
289 expect(aliceResult.data?.authorization).toBe("Bearer alice-vercel-token");285 const aliceData = aliceResult.data as EchoHeaders | null;
290 expect(aliceResult.data?.token).toBe("alice-team");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 {289 const bobResult = unwrapInvocation(
293 data: { authorization?: string; token?: string } | null;290 yield* bobExec.tools.invoke("vercel.projects.list", {}, autoApprove),
294 error: unknown;291 );
295 };
296 expect(bobResult.error).toBeNull();292 expect(bobResult.error).toBeNull();
297 expect(bobResult.data?.authorization).toBe("Bearer bob-vercel-token");293 const bobData = bobResult.data as EchoHeaders | null;
298 expect(bobResult.data?.token).toBe("bob-team");294 expect(bobData?.authorization).toBe("Bearer bob-vercel-token");
295 expect(bobData?.token).toBe("bob-team");
299 296
300 // -------------------------------------------------------------297 // -------------------------------------------------------------
301 // 5. Scope attribution: each user's token is pinned to their298 // 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(450 const aliceResult = unwrapInvocation(
454 "vercel.projects.list",451 yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove),
455 {},452 );
456 autoApprove,
457 )) as { data: { authorization?: string } | null; error: unknown };
458 expect(aliceResult.error).toBeNull();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 {458 const bobResult = unwrapInvocation(
462 data: { authorization?: string } | null;459 yield* bobExec.tools.invoke("vercel.projects.list", {}, autoApprove),
463 error: unknown;460 );
464 };
465 expect(bobResult.error).toBeNull();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(557 const sharedResult = unwrapInvocation(
562 "vercel.projects.list",558 yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove),
563 {},559 );
564 autoApprove,
565 )) as { data: { authorization?: string } | null; error: unknown };
566 expect(sharedResult.error).toBeNull();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 yield* aliceExec.secrets.set(563 yield* aliceExec.secrets.set(
570 SetSecretInput.make({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(584 const overrideResult = unwrapInvocation(
591 "vercel.projects.list",585 yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove),
592 {},586 );
593 autoApprove,
594 )) as { data: { authorization?: string } | null; error: unknown };
595 expect(overrideResult.error).toBeNull();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 yield* aliceExec.openapi.removeSourceBinding(592 yield* aliceExec.openapi.removeSourceBinding(
599 "vercel",593 "vercel",
@@ -602,13 +596,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 2 unmodified lines
602 String(aliceScope.id),596 String(aliceScope.id),
603 );597 );
604 598
605 const fallbackResult = (yield* aliceExec.tools.invoke(599 const fallbackResult = unwrapInvocation(
606 "vercel.projects.list",600 yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove),
607 {},601 );
608 autoApprove,
609 )) as { data: { authorization?: string } | null; error: unknown };
610 expect(fallbackResult.error).toBeNull();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 yield* aliceExec.openapi.setSourceBinding(605 yield* aliceExec.openapi.setSourceBinding(
614 OpenApiSourceBindingInput.make({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 {807 const result = unwrapInvocation(
816 data: { authorization?: string } | null;808 yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove),
817 error: unknown;809 );
818 };
819 expect(result.error).toBeNull();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 {908 const result = unwrapInvocation(
918 data: { authorization?: string } | null;909 yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove),
919 error: unknown;910 );
920 };
921 911
922 expect(result.error).toBeNull();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 {1006 const result = unwrapInvocation(
1017 data: { authorization?: string } | null;1007 yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove),
1018 error: unknown;1008 );
1019 };
1020 1009
1021 expect(result.error).toBeNull();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});
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
28import {28import {
29 addOpenApiTestSource,29 addOpenApiTestSource,
30 serveOpenApiHttpApiTestServer,30 serveOpenApiHttpApiTestServer,
31 unwrapInvocation,
31} from "@executor-js/plugin-openapi/testing";32} from "@executor-js/plugin-openapi/testing";
32 33
33import { openApiPlugin } from "./plugin";34import { openApiPlugin } from "./plugin";
@@ -307,23 +308,25 @@ describe("OpenAPI multi-scope OAuth", () => { · 273 unmodified lines
307 // 4. Invoke through each exec — Authorization must carry that308 // 4. Invoke through each exec — Authorization must carry that
308 // user's token.309 // user's token.
309 // -------------------------------------------------------------310 // -------------------------------------------------------------
310 const aliceResult = (yield* aliceExec.tools.invoke(311 const aliceResult = unwrapInvocation(
311 "petstore.items.echoHeaders",312 yield* aliceExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),
312 {},313 );
313 autoApprove,
314 )) as { data: { authorization?: string } | null; error: unknown };
315 expect(aliceResult.error).toBeNull();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 expect(aliceBearer).toBeDefined();319 expect(aliceBearer).toBeDefined();
318 expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true);320 expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true);
319 321
320 const bobResult = (yield* bobExec.tools.invoke(322 const bobResult = unwrapInvocation(
321 "petstore.items.echoHeaders",323 yield* bobExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),
322 {},324 );
323 autoApprove,
324 )) as { data: { authorization?: string } | null; error: unknown };
325 expect(bobResult.error).toBeNull();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 expect(bobBearer).toBeDefined();330 expect(bobBearer).toBeDefined();
328 expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true);331 expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true);
329 expect(bobBearer).not.toBe(aliceBearer);332 expect(bobBearer).not.toBe(aliceBearer);
@@ -610,23 +613,25 @@ describe("OpenAPI multi-scope OAuth", () => { · 280 unmodified lines
610 // (4) Each user's invocation resolves their OWN row and gets613 // (4) Each user's invocation resolves their OWN row and gets
611 // their OWN token — not whatever the last signer happened to614 // their OWN token — not whatever the last signer happened to
612 // mint. This is the core multi-user regression.615 // mint. This is the core multi-user regression.
613 const aliceResult = (yield* aliceExec.tools.invoke(616 const aliceResult = unwrapInvocation(
614 "petstore.items.echoHeaders",617 yield* aliceExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),
615 {},618 );
616 autoApprove,
617 )) as { data: { authorization?: string } | null; error: unknown };
618 expect(aliceResult.error).toBeNull();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 expect(aliceBearer).toBeDefined();624 expect(aliceBearer).toBeDefined();
621 expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true);625 expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true);
622 626
623 const bobResult = (yield* bobExec.tools.invoke(627 const bobResult = unwrapInvocation(
624 "petstore.items.echoHeaders",628 yield* bobExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),
625 {},629 );
626 autoApprove,
627 )) as { data: { authorization?: string } | null; error: unknown };
628 expect(bobResult.error).toBeNull();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 expect(bobBearer).toBeDefined();635 expect(bobBearer).toBeDefined();
631 expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true);636 expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true);
632 expect(bobBearer).not.toBe(aliceBearer);637 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
35import {35import {
36 addOpenApiTestSource,36 addOpenApiTestSource,
37 serveOpenApiHttpApiTestServer,37 serveOpenApiHttpApiTestServer,
38 unwrapInvocation,
38} from "@executor-js/plugin-openapi/testing";39} from "@executor-js/plugin-openapi/testing";
39 40
40import { openApiPlugin } from "./plugin";41import { openApiPlugin } from "./plugin";
@@ -253,17 +254,16 @@ describe("OpenAPI oauth refresh", () => { · 212 unmodified lines
253 });254 });
254 yield* bindOAuthConnection(executor, scopeId, "conn-refresh-ok", auth);255 yield* bindOAuthConnection(executor, scopeId, "conn-refresh-ok", auth);
255 256
256 const result = (yield* executor.tools.invoke(257 const result = unwrapInvocation(
257 "petstore.items.echoHeaders",258 yield* executor.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),
258 {},259 );
259 autoApprove,
260 )) as { data: { authorization?: string } | null; error: unknown };
261 260
262 expect(result.error).toBeNull();261 expect(result.error).toBeNull();
262 const data = result.data as EchoHeaders | null;
263 // Proves the refresh landed: invoke carried the fresh token,263 // Proves the refresh landed: invoke carried the fresh token,
264 // not the expired one we seeded.264 // not the expired one we seeded.
265 expect(result.data?.authorization).not.toBe("Bearer expired-access-v1");265 expect(data?.authorization).not.toBe("Bearer expired-access-v1");
266 const bearer = result.data?.authorization?.replace(/^Bearer\s+/i, "");266 const bearer = data?.authorization?.replace(/^Bearer\s+/i, "");
267 expect(bearer).toBeDefined();267 expect(bearer).toBeDefined();
268 expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true);268 expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true);
269 const calls = refreshTokenRequests(yield* oauth.requests);269 const calls = refreshTokenRequests(yield* oauth.requests);
@@ -313,12 +313,9 @@ describe("OpenAPI oauth refresh", () => { · 43 unmodified lines
313 );313 );
314 314
315 for (const r of invokes) {315 for (const r of invokes) {
316 const res = r as {316 const res = unwrapInvocation(r);
317 data: { authorization?: string } | null;
318 error: unknown;
319 };
320 expect(res.error).toBeNull();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 expect(bearer).toBeDefined();319 expect(bearer).toBeDefined();
323 expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true);320 expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true);
324 }321 }
packages/plugins/openapi/src/sdk/plugin.test.ts+38−39
@@ -25,6 +25,7 @@ import { · 24 unmodified lines
25 addOpenApiTestSource,25 addOpenApiTestSource,
26 makeOpenApiHttpApiTestSourceConfig,26 makeOpenApiHttpApiTestSourceConfig,
27 serveOpenApiHttpApiTestServer,27 serveOpenApiHttpApiTestServer,
28 unwrapInvocation,
28} from "../testing";29} from "../testing";
29 30
30const autoApprove: InvokeOptions = { onElicitation: "accept-all" };31const 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 const preview = unwrapInvocation(
271 "executor.openapi.previewSpec",272 yield* executor.tools.invoke(
272 { spec: testApiSpec() },273 "executor.openapi.previewSpec",
273 autoApprove,274 { spec: testApiSpec() },
274 )) as { operationCount: number };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(322 const result = unwrapInvocation(
320 "executor.openapi.addSource",323 yield* executor.tools.invoke(
321 testApiSourceConfig({ scope: String(orgScope), namespace: "runtime" }),324 "executor.openapi.addSource",
322 autoApprove,325 testApiSourceConfig({ scope: String(orgScope), namespace: "runtime" }),
323 )) as { sourceId: string; toolCount: number };326 autoApprove,
327 ),
328 ).data as { sourceId: string; toolCount: number };
324 329
325 expect(result).toEqual({ sourceId: "runtime", toolCount: 4 });330 expect(result).toEqual({ sourceId: "runtime", toolCount: 4 });
326 expect(yield* executor.openapi.getSource("runtime", String(userScope))).toBeNull();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(565 const result = unwrapInvocation(
561 "authed.items.echoHeaders",566 yield* executor.tools.invoke("authed.items.echoHeaders", {}, autoApprove),
562 {},567 );
563 autoApprove,
564 )) as {
565 data: { authorization?: string; "x-static"?: string } | null;
566 error: unknown;
567 };
568 568
569 expect(result.error).toBeNull();569 expect(result.error).toBeNull();
570 const data = result.data!;570 const data = result.data as { authorization?: string; "x-static"?: string };
571 expect(data.authorization).toBe("Bearer secret-value-123");571 expect(data.authorization).toBe("Bearer secret-value-123");
572 expect(data["x-static"]).toBe("hello");572 expect(data["x-static"]).toBe("hello");
573 }),573 }),
@@ -751,10 +751,9 @@ describe("OpenAPI Plugin", () => { · 177 unmodified lines
751 namespace: "test",751 namespace: "test",
752 });752 });
753 753
754 const result = (yield* executor.tools.invoke("test.items.listItems", {}, autoApprove)) as {754 const result = unwrapInvocation(
755 data: unknown;755 yield* executor.tools.invoke("test.items.listItems", {}, autoApprove),
756 error: unknown;756 );
757 };
758 expect(result.error).toBeNull();757 expect(result.error).toBeNull();
759 expect(result.data).toEqual(ITEMS);758 expect(result.data).toEqual(ITEMS);
760 }),759 }),
@@ -780,11 +779,9 @@ describe("OpenAPI Plugin", () => { · 19 unmodified lines
780 namespace: "test",779 namespace: "test",
781 });780 });
782 781
783 const result = (yield* executor.tools.invoke(782 const result = unwrapInvocation(
784 "test.items.getItem",783 yield* executor.tools.invoke("test.items.getItem", { itemId: "2" }, autoApprove),
785 { itemId: "2" },784 );
786 autoApprove,
787 )) as { data: unknown; error: unknown };
788 expect(result.error).toBeNull();785 expect(result.error).toBeNull();
789 expect(result.data).toEqual({ id: 2, name: "Gadget" });786 expect(result.data).toEqual({ id: 2, name: "Gadget" });
790 }),787 }),
@@ -810,16 +807,18 @@ describe("OpenAPI Plugin", () => { · 19 unmodified lines
810 namespace: "records",807 namespace: "records",
811 });808 });
812 809
813 const result = (yield* executor.tools.invoke(810 const result = unwrapInvocation(
814 "records.items.queryRows",811 yield* executor.tools.invoke(
815 {812 "records.items.queryRows",
816 entryTypeId: "18538",813 {
817 query: JSON.stringify([{ DisplayName: "Example" }]),814 entryTypeId: "18538",
818 limit: 10,815 query: JSON.stringify([{ DisplayName: "Example" }]),
819 skip: 0,816 limit: 10,
820 },817 skip: 0,
821 autoApprove,818 },
822 )) as { data: unknown; error: unknown };819 autoApprove,
820 ),
821 );
823 822
824 expect(result.data).toBeNull();823 expect(result.data).toBeNull();
825 expect(result.error).toEqual(824 expect(result.error).toEqual(
packages/plugins/openapi/src/sdk/plugin.ts+88−3
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
7 SecretId,7 SecretId,
8 SourceDetectionResult,8 SourceDetectionResult,
9 StorageError,9 StorageError,
10 ToolResult,
10 definePlugin,11 definePlugin,
11 tool,12 tool,
12 resolveSecretBackedMap,13 resolveSecretBackedMap,
@@ -57,6 +58,77 @@ import { · 44 unmodified lines
57// Plugin config58// Plugin config
58// ---------------------------------------------------------------------------59// ---------------------------------------------------------------------------
59 60
61const STRINGIFIED_BODY_CAP = 1024;
62const UpstreamMessageBody = Schema.Struct({ message: Schema.String });
63const UpstreamErrorMessageBody = Schema.Struct({ errorMessage: Schema.String });
64const UpstreamNestedErrorBody = Schema.Struct({ error: UpstreamMessageBody });
65const 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});
74const UpstreamDescriptionBody = Schema.Struct({
75 detail: Schema.optional(Schema.String),
76 title: Schema.optional(Schema.String),
77 description: Schema.optional(Schema.String),
78});
79
80const decodeUpstreamMessageBody = Schema.decodeUnknownOption(UpstreamMessageBody);
81const decodeUpstreamErrorMessageBody = Schema.decodeUnknownOption(UpstreamErrorMessageBody);
82const decodeUpstreamNestedErrorBody = Schema.decodeUnknownOption(UpstreamNestedErrorBody);
83const decodeUpstreamErrorsArrayBody = Schema.decodeUnknownOption(UpstreamErrorsArrayBody);
84const decodeUpstreamDescriptionBody = Schema.decodeUnknownOption(UpstreamDescriptionBody);
85
86const 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
97const 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.
102const 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
60export type HeaderValue = HeaderValueValue;132export type HeaderValue = HeaderValueValue;
61export type ConfiguredHeaderValue = ConfiguredHeaderValueValue;133export type ConfiguredHeaderValue = ConfiguredHeaderValueValue;
62export type OpenApiHeaderInput = HeaderValue | ConfiguredHeaderValue;134export type OpenApiHeaderInput = HeaderValue | ConfiguredHeaderValue;
@@ -1193,7 +1265,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1130 unmodified lines
1193 name: "previewSpec",1265 name: "previewSpec",
1194 description: "Preview an OpenAPI document before adding it as a source",1266 description: "Preview an OpenAPI document before adding it as a source",
1195 inputSchema: PreviewSpecInputStandardSchema,1267 inputSchema: PreviewSpecInputStandardSchema,
1196 execute: (input) => self.previewSpec(input),1268 execute: (input) => Effect.map(self.previewSpec(input), ToolResult.ok),
1197 }),1269 }),
1198 tool({1270 tool({
1199 name: "addSource",1271 name: "addSource",
@@ -1204,7 +1276,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 4 unmodified lines
1204 },1276 },
1205 inputSchema: AddSourceInputStandardSchema,1277 inputSchema: AddSourceInputStandardSchema,
1206 outputSchema: AddSourceOutputStandardSchema,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 httpClientLayer,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 resolveAnnotations: ({ ctx, sourceId, toolRows }) =>1374 resolveAnnotations: ({ ctx, sourceId, toolRows }) =>
packages/plugins/openapi/src/sdk/upstream-failures.test.ts+5−4
@@ -37,6 +37,7 @@ import { · 36 unmodified lines
37 makeOpenApiTestSourceConfig,37 makeOpenApiTestSourceConfig,
38 type OpenApiTestServerShape,38 type OpenApiTestServerShape,
39 serveOpenApiHttpApiTestServer,39 serveOpenApiHttpApiTestServer,
40 unwrapInvocation,
40} from "../testing";41} from "../testing";
41 42
42import { openApiPlugin } from "./plugin";43import { openApiPlugin } from "./plugin";
@@ -303,10 +304,10 @@ describe("OpenAPI upstream failure modes", () => { · 260 unmodified lines
303 const { baseUrl } = yield* slowServer;304 const { baseUrl } = yield* slowServer;
304 const executor = yield* buildExecutor(baseUrl);305 const executor = yield* buildExecutor(baseUrl);
305 306
306 const result = yield* executor.tools.invoke("f.things.listThings", {}, autoApprove);307 const result = unwrapInvocation(
307 // Empty array via .data envelope or directly — accept either shape.308 yield* executor.tools.invoke("f.things.listThings", {}, autoApprove),
308 const data = (result as { data?: unknown }).data ?? result;309 );
309 expect(data).toEqual([]);310 expect(result.data).toEqual([]);
310 }),311 }),
311 );312 );
312});313});
packages/plugins/openapi/src/testing/index.ts+54−2
@@ -1,4 +1,4 @@
1import { Context, Data, Effect, Layer, Predicate, Ref, Schema, Scope } from "effect";1import { Context, Data, Effect, Layer, Option, Predicate, Ref, Schema, Scope } from "effect";
2import {2import {
3 HttpClient,3 HttpClient,
4 HttpRouter,4 HttpRouter,
@@ -13,7 +13,7 @@ import { · 8 unmodified lines
13 OpenApi,13 OpenApi,
14} from "effect/unstable/httpapi";14} from "effect/unstable/httpapi";
15import { OAuthTestServer, serveTestHttpServerLayer } from "@executor-js/sdk/testing";15import { OAuthTestServer, serveTestHttpServerLayer } from "@executor-js/sdk/testing";
16import type { ScopeId } from "@executor-js/sdk/core";16import { isToolResult, type ScopeId } from "@executor-js/sdk/core";
17import type { OpenApiPluginExtension, OpenApiSpecConfig } from "../sdk/plugin";17import type { OpenApiPluginExtension, OpenApiSpecConfig } from "../sdk/plugin";
18 18
19export class OpenApiTestServerAddressError extends Data.TaggedError(19export class OpenApiTestServerAddressError extends Data.TaggedError(
@@ -618,3 +618,55 @@ export const TestLayers = { · 598 unmodified lines
618 echo: OpenApiEchoTestServer.layer,618 echo: OpenApiEchoTestServer.layer,
619 echoWithOAuth: OpenApiEchoTestServer.layerWithOAuth,619 echoWithOAuth: OpenApiEchoTestServer.layerWithOAuth,
620};620};
621
622const OpenApiTransportEnvelope = Schema.Struct({
623 status: Schema.Number,
624 headers: Schema.Record(Schema.String, Schema.String),
625 data: Schema.Unknown,
626});
627
628const decodeOpenApiTransportEnvelope = Schema.decodeUnknownOption(OpenApiTransportEnvelope);
629
630export 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
637export 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
256 const client = makeFakeClient({ rejectReadNamesLongerThan: 80 });256 const client = makeFakeClient({ rejectReadNamesLongerThan: 80 });
257 const executor = yield* makeExecutor(client);257 const executor = yield* makeExecutor(client);
258 const longSecretId = SecretId.make(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 yield* executor.secrets.set(262 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