History

Share secret-backed credential values (#454)

Rhys Sullivan committed 6 months ago

Commit 9639a681 parent 899a99e17 files · +768 −954Browse files

Unified · Split

17 files
  1. packages/core/api/src/handlers/oauth.ts +31 −39
  2. packages/core/api/src/oauth/api.ts +3 −15
  3. packages/core/sdk/src/index.ts +10 −15
  4. packages/core/sdk/src/secret-backed-value.ts +72 −0
  5. packages/plugins/google-discovery/src/api/group.ts +2 −13
  6. packages/plugins/google-discovery/src/sdk/plugin.ts +61 −120
  7. packages/plugins/google-discovery/src/sdk/types.ts +4 −11
  8. packages/plugins/graphql/src/sdk/invoke.ts +8 −26
  9. packages/plugins/graphql/src/sdk/types.ts +14 −37
  10. packages/plugins/mcp/src/api/group.ts +7 −16
  11. packages/plugins/mcp/src/sdk/plugin.ts +521 −587
  12. packages/plugins/mcp/src/sdk/types.ts +3 −10
  13. packages/plugins/openapi/src/api/group.ts +1 −9
  14. packages/plugins/openapi/src/sdk/plugin.ts +18 −25
  15. packages/plugins/openapi/src/sdk/types.ts +6 −16
  16. packages/react/src/plugins/http-credentials.tsx +2 −7
  17. packages/react/src/plugins/oauth-sign-in.tsx +5 −8
packages/core/api/src/handlers/oauth.ts+31−39
@@ -14,8 +14,10 @@ import { · 13 unmodified lines
14 OAuthProbeError,14 OAuthProbeError,
15 OAuthSessionNotFoundError,15 OAuthSessionNotFoundError,
16 OAuthStartError,16 OAuthStartError,
17 resolveSecretBackedMap,
17 type Executor,18 type Executor,
18 type OAuthStrategy,19 type OAuthStrategy,
20 type SecretBackedValue,
19} from "@executor/sdk";21} from "@executor/sdk";
20 22
21import { ExecutorApi } from "../api";23import { ExecutorApi } from "../api";
@@ -24,32 +26,23 @@ import { ExecutorService } from "../services"; · 2 unmodified lines
24 26
25const OAUTH_POPUP_CHANNEL = OAUTH_POPUP_MESSAGE_TYPE;27const OAUTH_POPUP_CHANNEL = OAUTH_POPUP_MESSAGE_TYPE;
26 28
27type SecretBackedValue =29const resolveOAuthSecretBackedMap = (
28 | string
29 | { readonly secretId: string; readonly prefix?: string };
30
31const resolveSecretBackedMap = (
32 executor: Executor,30 executor: Executor,
33 values: Record<string, SecretBackedValue> | undefined,31 values: Record<string, SecretBackedValue> | undefined,
34 makeError: (message: string) => OAuthProbeError | OAuthStartError,32 makeError: (message: string) => OAuthProbeError | OAuthStartError,
35) =>33) =>
36 Effect.gen(function* () {34 resolveSecretBackedMap({
37 const resolved: Record<string, string> = {};35 values,
38 for (const [name, value] of Object.entries(values ?? {})) {36 getSecret: executor.secrets.get,
39 if (typeof value === "string") {37 onMissing: (name) => makeError(`Secret not found for "${name}"`),
40 resolved[name] = value;38 onError: (_error, name) => makeError(`Secret not found for "${name}"`),
41 continue;39 }).pipe(
42 }40 Effect.mapError((error) =>
43 const secret = yield* executor.secrets.get(value.secretId).pipe(41 error instanceof OAuthProbeError || error instanceof OAuthStartError
44 Effect.mapError(() => makeError(`Secret not found for "${name}"`)),42 ? error
45 );43 : makeError("Secret resolution failed"),
46 if (secret === null) {44 ),
47 return yield* Effect.fail(makeError(`Secret not found for "${name}"`));45 );
48 }
49 resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret;
50 }
51 return Object.keys(resolved).length > 0 ? resolved : undefined;
52 });
53 46
54const toPopupErrorMessage = (error: unknown): string => {47const toPopupErrorMessage = (error: unknown): string => {
55 if (error instanceof OAuthStartError) return error.message;48 if (error instanceof OAuthStartError) return error.message;
@@ -67,12 +60,12 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 11 unmodified lines
67 capture(60 capture(
68 Effect.gen(function* () {61 Effect.gen(function* () {
69 const executor = yield* ExecutorService;62 const executor = yield* ExecutorService;
70 const headers = yield* resolveSecretBackedMap(63 const headers = yield* resolveOAuthSecretBackedMap(
71 executor,64 executor,
72 payload.headers,65 payload.headers,
73 (message) => new OAuthProbeError({ message }),66 (message) => new OAuthProbeError({ message }),
74 );67 );
75 const queryParams = yield* resolveSecretBackedMap(68 const queryParams = yield* resolveOAuthSecretBackedMap(
76 executor,69 executor,
77 payload.queryParams,70 payload.queryParams,
78 (message) => new OAuthProbeError({ message }),71 (message) => new OAuthProbeError({ message }),
@@ -89,14 +82,13 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 10 unmodified lines
89 capture(82 capture(
90 Effect.gen(function* () {83 Effect.gen(function* () {
91 const executor = yield* ExecutorService;84 const executor = yield* ExecutorService;
92 const tokenScope =85 const tokenScope = payload.tokenScope ?? (executor.scopes[0]!.id as unknown as string);
93 payload.tokenScope ?? (executor.scopes[0]!.id as unknown as string);86 const headers = yield* resolveOAuthSecretBackedMap(
94 const headers = yield* resolveSecretBackedMap(
95 executor,87 executor,
96 payload.headers,88 payload.headers,
97 (message) => new OAuthStartError({ message }),89 (message) => new OAuthStartError({ message }),
98 );90 );
99 const queryParams = yield* resolveSecretBackedMap(91 const queryParams = yield* resolveOAuthSecretBackedMap(
100 executor,92 executor,
101 payload.queryParams,93 payload.queryParams,
102 (message) => new OAuthStartError({ message }),94 (message) => new OAuthStartError({ message }),
@@ -144,18 +136,18 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 41 unmodified lines
144 const executor = yield* ExecutorService;136 const executor = yield* ExecutorService;
145 const html = yield* runOAuthCallback({137 const html = yield* runOAuthCallback({
146 complete: ({ state, code, error }) =>138 complete: ({ state, code, error }) =>
147 executor.oauth.complete({139 executor.oauth
148 state,140 .complete({
149 code: code ?? undefined,141 state,
150 error: error ?? undefined,142 code: code ?? undefined,
151 }).pipe(143 error: error ?? undefined,
152 Effect.tapErrorCause((cause) =>144 })
153 Effect.logError("OAuth callback completion failed", cause),145 .pipe(
154 ),146 Effect.tapErrorCause((cause) =>
155 Effect.catchAllCause(() =>147 Effect.logError("OAuth callback completion failed", cause),
156 Effect.fail(new Error("Authentication failed")),148 ),
149 Effect.catchAllCause(() => Effect.fail(new Error("Authentication failed"))),
157 ),150 ),
158 ),
159 urlParams,151 urlParams,
160 toErrorMessage: toPopupErrorMessage,152 toErrorMessage: toPopupErrorMessage,
161 channelName: OAUTH_POPUP_CHANNEL,153 channelName: OAUTH_POPUP_CHANNEL,
packages/core/api/src/oauth/api.ts+3−15
@@ -17,20 +17,12 @@ import { · 16 unmodified lines
17 OAuthStartError,17 OAuthStartError,
18 OAuthStrategySchema,18 OAuthStrategySchema,
19 ScopeId,19 ScopeId,
20 SecretBackedMap,
20} from "@executor/sdk";21} from "@executor/sdk";
21 22
22import { InternalError } from "../observability";23import { InternalError } from "../observability";
23 24
24const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId);25const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId);
25const SecretBackedValue = Schema.Union(
26 Schema.String,
27 Schema.Struct({
28 secretId: Schema.String,
29 prefix: Schema.optional(Schema.String),
30 }),
31);
32const SecretBackedMap = Schema.Record({ key: Schema.String, value: SecretBackedValue });
33
34// ---------------------------------------------------------------------------26// ---------------------------------------------------------------------------
35// Probe — decide between dynamic-DCR and paste-your-credentials flows27// Probe — decide between dynamic-DCR and paste-your-credentials flows
36// ---------------------------------------------------------------------------28// ---------------------------------------------------------------------------
@@ -42,9 +34,7 @@ const ProbePayload = Schema.Struct({ · 5 unmodified lines
42});34});
43 35
44const ProbeResponse = Schema.Struct({36const ProbeResponse = Schema.Struct({
45 resourceMetadata: Schema.NullOr(37 resourceMetadata: Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.Unknown })),
46 Schema.Record({ key: Schema.String, value: Schema.Unknown }),
47 ),
48 resourceMetadataUrl: Schema.NullOr(Schema.String),38 resourceMetadataUrl: Schema.NullOr(Schema.String),
49 authorizationServerMetadata: Schema.NullOr(39 authorizationServerMetadata: Schema.NullOr(
50 Schema.Record({ key: Schema.String, value: Schema.Unknown }),40 Schema.Record({ key: Schema.String, value: Schema.Unknown }),
@@ -91,9 +81,7 @@ const StartResponse = Schema.Struct({ · 40 unmodified lines
91 * `client-credentials` (no redirect). */81 * `client-credentials` (no redirect). */
92 authorizationUrl: Schema.NullOr(Schema.String),82 authorizationUrl: Schema.NullOr(Schema.String),
93 /** Filled for strategies that mint the Connection inline. */83 /** Filled for strategies that mint the Connection inline. */
94 completedConnection: Schema.NullOr(84 completedConnection: Schema.NullOr(Schema.Struct({ connectionId: Schema.String })),
95 Schema.Struct({ connectionId: Schema.String }),
96 ),
97});85});
98 86
99// ---------------------------------------------------------------------------87// ---------------------------------------------------------------------------
packages/core/sdk/src/index.ts+10−15
@@ -93,11 +93,15 @@ export { · 92 unmodified lines
93} from "./policies";93} from "./policies";
94 94
95// Secrets95// Secrets
96export { SecretRef, SetSecretInput, type SecretProvider } from "./secrets";
97
96export {98export {
97 SecretRef,99 SecretBackedMap,
98 SetSecretInput,100 SecretBackedValue,
99 type SecretProvider,101 isSecretBackedRef,
100} from "./secrets";102 resolveSecretBackedMap,
103 type ResolveSecretBackedMapOptions,
104} from "./secret-backed-value";
101 105
102// Connections106// Connections
103export {107export {
@@ -236,22 +240,13 @@ export { · 132 unmodified lines
236} from "./executor";240} from "./executor";
237 241
238// CLI config242// CLI config
239export {243export { defineExecutorConfig, type ExecutorCliConfig, type ExecutorDialect } from "./config";
240 defineExecutorConfig,
241 type ExecutorCliConfig,
242 type ExecutorDialect,
243} from "./config";
244 244
245// Test helper245// Test helper
246export { makeTestConfig } from "./testing";246export { makeTestConfig } from "./testing";
247 247
248// JSON schema $ref helpers (used by openapi for $defs handling)248// JSON schema $ref helpers (used by openapi for $defs handling)
249export {249export { hoistDefinitions, collectRefs, reattachDefs, normalizeRefs } from "./schema-refs";
250 hoistDefinitions,
251 collectRefs,
252 reattachDefs,
253 normalizeRefs,
254} from "./schema-refs";
255 250
256// TypeScript preview generation from JSON schemas251// TypeScript preview generation from JSON schemas
257export {252export {
packages/core/sdk/src/secret-backed-value.ts+72−0
@@ -0,0 +1,72 @@
1import { Effect, Schema } from "effect";
2
3export const SecretBackedValue = Schema.Union(
4 Schema.String,
5 Schema.Struct({
6 secretId: Schema.String,
7 prefix: Schema.optional(Schema.String),
8 }),
9);
10export type SecretBackedValue = typeof SecretBackedValue.Type;
11
12export const SecretBackedMap = Schema.Record({
13 key: Schema.String,
14 value: SecretBackedValue,
15});
16export type SecretBackedMap = typeof SecretBackedMap.Type;
17
18export const isSecretBackedRef = (
19 value: SecretBackedValue,
20): value is Extract<SecretBackedValue, { readonly secretId: string }> => typeof value !== "string";
21
22export type ResolveSecretBackedMapOptions<E, E2> = {
23 readonly values: Record<string, SecretBackedValue> | undefined;
24 readonly getSecret: (secretId: string) => Effect.Effect<string | null, E>;
25 readonly onMissing: (
26 name: string,
27 value: Extract<SecretBackedValue, { readonly secretId: string }>,
28 ) => E2;
29 readonly onError?: (
30 error: E,
31 name: string,
32 value: Extract<SecretBackedValue, { readonly secretId: string }>,
33 ) => E | E2;
34 readonly missing?: "fail" | "drop";
35};
36
37export const resolveSecretBackedMap = <E, E2 = E>({
38 values,
39 getSecret,
40 onMissing,
41 onError,
42 missing = "fail",
43}: ResolveSecretBackedMapOptions<E, E2>): Effect.Effect<
44 Record<string, string> | undefined,
45 E | E2
46> => {
47 const entries = Object.entries(values ?? {});
48 if (entries.length === 0) return Effect.succeed(undefined);
49
50 return Effect.gen(function* () {
51 const resolved: Record<string, string> = {};
52
53 for (const [name, value] of entries) {
54 if (typeof value === "string") {
55 resolved[name] = value;
56 continue;
57 }
58
59 const secret = yield* getSecret(value.secretId).pipe(
60 Effect.mapError((error) => onError?.(error, name, value) ?? error),
61 );
62 if (secret === null) {
63 if (missing === "drop") continue;
64 return yield* Effect.fail(onMissing(name, value));
65 }
66
67 resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret;
68 }
69
70 return Object.keys(resolved).length > 0 ? resolved : undefined;
71 });
72};
packages/plugins/google-discovery/src/api/group.ts+2−13
@@ -1,12 +1,9 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId } from "@executor/sdk";3import { ScopeId, SecretBackedValue } from "@executor/sdk";
4import { InternalError } from "@executor/api";4import { InternalError } from "@executor/api";
5 5
6import {6import { GoogleDiscoveryParseError, GoogleDiscoverySourceError } from "../sdk/errors";
7 GoogleDiscoveryParseError,
8 GoogleDiscoverySourceError,
9} from "../sdk/errors";
10import { GoogleDiscoveryStoredSourceSchema } from "../sdk/stored-source";7import { GoogleDiscoveryStoredSourceSchema } from "../sdk/stored-source";
11 8
12export { HttpApiSchema };9export { HttpApiSchema };
@@ -14,14 +11,6 @@ export { HttpApiSchema }; · 1 unmodified line
14const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId);11const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId);
15const namespaceParam = HttpApiSchema.param("namespace", Schema.String);12const namespaceParam = HttpApiSchema.param("namespace", Schema.String);
16 13
17const SecretBackedValue = Schema.Union(
18 Schema.String,
19 Schema.Struct({
20 secretId: Schema.String,
21 prefix: Schema.optional(Schema.String),
22 }),
23);
24
25const DiscoveryCredentialsPayload = Schema.Struct({14const DiscoveryCredentialsPayload = Schema.Struct({
26 headers: Schema.optional(Schema.Record({ key: Schema.String, value: SecretBackedValue })),15 headers: Schema.optional(Schema.Record({ key: Schema.String, value: SecretBackedValue })),
27 queryParams: Schema.optional(Schema.Record({ key: Schema.String, value: SecretBackedValue })),16 queryParams: Schema.optional(Schema.Record({ key: Schema.String, value: SecretBackedValue })),
packages/plugins/google-discovery/src/sdk/plugin.ts+61−120
@@ -3,6 +3,7 @@ import { Effect, Option } from "effect"; · 2 unmodified lines
3import {3import {
4 SourceDetectionResult,4 SourceDetectionResult,
5 definePlugin,5 definePlugin,
6 resolveSecretBackedMap,
6 type PluginCtx,7 type PluginCtx,
7 type StorageFailure,8 type StorageFailure,
8 type ToolAnnotations,9 type ToolAnnotations,
@@ -16,10 +17,7 @@ import { · 7 unmodified lines
16} from "./binding-store";17} from "./binding-store";
17import { extractGoogleDiscoveryManifest } from "./document";18import { extractGoogleDiscoveryManifest } from "./document";
18import { annotationsForOperation, invokeGoogleDiscoveryTool } from "./invoke";19import { annotationsForOperation, invokeGoogleDiscoveryTool } from "./invoke";
19import {20import { GoogleDiscoveryParseError, GoogleDiscoverySourceError } from "./errors";
20 GoogleDiscoveryParseError,
21 GoogleDiscoverySourceError,
22} from "./errors";
23import type {21import type {
24 GoogleDiscoveryAuth,22 GoogleDiscoveryAuth,
25 GoogleDiscoveryFetchCredentials,23 GoogleDiscoveryFetchCredentials,
@@ -93,10 +91,7 @@ export interface GoogleDiscoveryPluginExtension { · 67 unmodified lines
93 { readonly toolCount: number; readonly namespace: string },91 { readonly toolCount: number; readonly namespace: string },
94 GoogleDiscoveryParseError | GoogleDiscoverySourceError | StorageFailure92 GoogleDiscoveryParseError | GoogleDiscoverySourceError | StorageFailure
95 >;93 >;
96 readonly removeSource: (94 readonly removeSource: (namespace: string, scope: string) => Effect.Effect<void, StorageFailure>;
97 namespace: string,
98 scope: string,
99 ) => Effect.Effect<void, StorageFailure>;
100 readonly getSource: (95 readonly getSource: (
101 namespace: string,96 namespace: string,
102 scope: string,97 scope: string,
@@ -143,57 +138,50 @@ const resolveGoogleDiscoveryCredentials = ( · 40 unmodified lines
143 credentials: GoogleDiscoveryFetchCredentials | undefined,138 credentials: GoogleDiscoveryFetchCredentials | undefined,
144 ctx: PluginCtx<GoogleDiscoveryStore>,139 ctx: PluginCtx<GoogleDiscoveryStore>,
145): Effect.Effect<140): Effect.Effect<
146 | { headers?: Record<string, string>; queryParams?: Record<string, string> }141 { headers?: Record<string, string>; queryParams?: Record<string, string> } | undefined,
147 | undefined,
148 GoogleDiscoverySourceError142 GoogleDiscoverySourceError
149> =>143> =>
150 Effect.gen(function* () {144 Effect.gen(function* () {
151 if (!credentials) return undefined;145 if (!credentials) return undefined;
152 const headers: Record<string, string> = {};146 const headers = yield* resolveSecretBackedMap({
153 for (const [name, value] of Object.entries(credentials.headers ?? {})) {147 values: credentials.headers,
154 if (typeof value === "string") {148 getSecret: ctx.secrets.get,
155 headers[name] = value;149 onMissing: (name) =>
156 continue;150 new GoogleDiscoverySourceError({
157 }
158 const secret = yield* ctx.secrets.get(value.secretId).pipe(
159 Effect.mapError(
160 () =>
161 new GoogleDiscoverySourceError({
162 message: `Secret not found for header "${name}"`,
163 }),
164 ),
165 );
166 if (secret === null) {
167 return yield* new GoogleDiscoverySourceError({
168 message: `Secret not found for header "${name}"`,151 message: `Secret not found for header "${name}"`,
169 });152 }),
170 }153 onError: (_error, name) =>
171 headers[name] = value.prefix ? `${value.prefix}${secret}` : secret;154 new GoogleDiscoverySourceError({
172 }155 message: `Secret not found for header "${name}"`,
173 const queryParams: Record<string, string> = {};156 }),
174 for (const [name, value] of Object.entries(credentials.queryParams ?? {})) {157 }).pipe(
175 if (typeof value === "string") {158 Effect.mapError((err) =>
176 queryParams[name] = value;159 err instanceof GoogleDiscoverySourceError
177 continue;160 ? err
178 }161 : new GoogleDiscoverySourceError({ message: "Secret resolution failed" }),
179 const secret = yield* ctx.secrets.get(value.secretId).pipe(162 ),
180 Effect.mapError(163 );
181 () =>164 const queryParams = yield* resolveSecretBackedMap({
182 new GoogleDiscoverySourceError({165 values: credentials.queryParams,
183 message: `Secret not found for query parameter "${name}"`,166 getSecret: ctx.secrets.get,
184 }),167 onMissing: (name) =>
185 ),168 new GoogleDiscoverySourceError({
186 );
187 if (secret === null) {
188 return yield* new GoogleDiscoverySourceError({
189 message: `Secret not found for query parameter "${name}"`,169 message: `Secret not found for query parameter "${name}"`,
190 });170 }),
191 }171 onError: (_error, name) =>
192 queryParams[name] = value.prefix ? `${value.prefix}${secret}` : secret;172 new GoogleDiscoverySourceError({
193 }173 message: `Secret not found for query parameter "${name}"`,
174 }),
175 }).pipe(
176 Effect.mapError((err) =>
177 err instanceof GoogleDiscoverySourceError
178 ? err
179 : new GoogleDiscoverySourceError({ message: "Secret resolution failed" }),
180 ),
181 );
194 return {182 return {
195 ...(Object.keys(headers).length > 0 ? { headers } : {}),183 ...(headers ? { headers } : {}),
196 ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}),184 ...(queryParams ? { queryParams } : {}),
197 };185 };
198 });186 });
199 187
@@ -207,9 +195,7 @@ const fetchDiscoveryDocument = ( · 7 unmodified lines
207 Effect.tryPromise({195 Effect.tryPromise({
208 try: async () => {196 try: async () => {
209 const url = new URL(normalizeDiscoveryUrl(discoveryUrl));197 const url = new URL(normalizeDiscoveryUrl(discoveryUrl));
210 for (const [key, value] of Object.entries(198 for (const [key, value] of Object.entries(credentials?.queryParams ?? {})) {
211 credentials?.queryParams ?? {},
212 )) {
213 url.searchParams.set(key, value);199 url.searchParams.set(key, value);
214 }200 }
215 const response = await fetch(url.toString(), {201 const response = await fetch(url.toString(), {
@@ -237,14 +223,9 @@ const normalizeSlug = (value: string): string => · 21 unmodified lines
237 .replace(/[^a-z0-9]+/g, "_")223 .replace(/[^a-z0-9]+/g, "_")
238 .replace(/^_+|_+$/g, "");224 .replace(/^_+|_+$/g, "");
239 225
240const deriveNamespace = (input: {226const deriveNamespace = (input: { name: string; service: string; version: string }): string =>
241 name: string;
242 service: string;
243 version: string;
244}): string =>
245 normalizeSlug(227 normalizeSlug(
246 input.name ||228 input.name || `google_${input.service}_${input.version.replace(/[^a-zA-Z0-9]+/g, "_")}`,
247 `google_${input.service}_${input.version.replace(/[^a-zA-Z0-9]+/g, "_")}`,
248 ) || `google_${input.service}`;229 ) || `google_${input.service}`;
249 230
250// Connection refresh state is owned by the canonical `"oauth2"`231// Connection refresh state is owned by the canonical `"oauth2"`
@@ -281,8 +262,7 @@ const registerManifest = ( · 30 unmodified lines
281 name: method.toolPath,262 name: method.toolPath,
282 description: Option.getOrElse(263 description: Option.getOrElse(
283 method.description,264 method.description,
284 () =>265 () => `${method.binding.method.toUpperCase()} ${method.binding.pathTemplate}`,
285 `${method.binding.method.toUpperCase()} ${method.binding.pathTemplate}`,
286 ),266 ),
287 inputSchema: Option.getOrUndefined(method.inputSchema),267 inputSchema: Option.getOrUndefined(method.inputSchema),
288 outputSchema: Option.getOrUndefined(method.outputSchema),268 outputSchema: Option.getOrUndefined(method.outputSchema),
@@ -300,12 +280,7 @@ const registerManifest = ( · 11 unmodified lines
300 yield* Effect.forEach(280 yield* Effect.forEach(
301 manifest.methods,281 manifest.methods,
302 (method) =>282 (method) =>
303 ctx.storage.putBinding(283 ctx.storage.putBinding(`${namespace}.${method.toolPath}`, namespace, scope, method.binding),
304 `${namespace}.${method.toolPath}`,
305 namespace,
306 scope,
307 method.binding,
308 ),
309 { discard: true },284 { discard: true },
310 );285 );
311 286
@@ -332,30 +307,21 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 20 unmodified lines
332 ({307 ({
333 probeDiscovery: (input) =>308 probeDiscovery: (input) =>
334 Effect.gen(function* () {309 Effect.gen(function* () {
335 const discoveryUrl =310 const discoveryUrl = typeof input === "string" ? input : input.discoveryUrl;
336 typeof input === "string" ? input : input.discoveryUrl;
337 const credentials =311 const credentials =
338 typeof input === "string"312 typeof input === "string"
339 ? undefined313 ? undefined
340 : yield* resolveGoogleDiscoveryCredentials(314 : yield* resolveGoogleDiscoveryCredentials(input.credentials, ctx);
341 input.credentials,
342 ctx,
343 );
344 const text = yield* fetchDiscoveryDocument(discoveryUrl, credentials);315 const text = yield* fetchDiscoveryDocument(discoveryUrl, credentials);
345 const manifest = yield* extractGoogleDiscoveryManifest(text);316 const manifest = yield* extractGoogleDiscoveryManifest(text);
346 const scopes = Object.keys(317 const scopes = Object.keys(
347 manifest.oauthScopes._tag === "Some"318 manifest.oauthScopes._tag === "Some" ? manifest.oauthScopes.value : {},
348 ? manifest.oauthScopes.value
349 : {},
350 ).sort();319 ).sort();
351 const operations = manifest.methods.map((method) => ({320 const operations = manifest.methods.map((method) => ({
352 toolPath: method.toolPath,321 toolPath: method.toolPath,
353 method: method.binding.method,322 method: method.binding.method,
354 pathTemplate: method.binding.pathTemplate,323 pathTemplate: method.binding.pathTemplate,
355 description:324 description: method.description._tag === "Some" ? method.description.value : null,
356 method.description._tag === "Some"
357 ? method.description.value
358 : null,
359 }));325 }));
360 return {326 return {
361 name:327 name:
@@ -374,14 +340,8 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 12 unmodified lines
374 addSource: (input) =>340 addSource: (input) =>
375 ctx.transaction(341 ctx.transaction(
376 Effect.gen(function* () {342 Effect.gen(function* () {
377 const credentials = yield* resolveGoogleDiscoveryCredentials(343 const credentials = yield* resolveGoogleDiscoveryCredentials(input.credentials, ctx);
378 input.credentials,344 const text = yield* fetchDiscoveryDocument(input.discoveryUrl, credentials);
379 ctx,
380 );
381 const text = yield* fetchDiscoveryDocument(
382 input.discoveryUrl,
383 credentials,
384 );
385 const manifest = yield* extractGoogleDiscoveryManifest(text);345 const manifest = yield* extractGoogleDiscoveryManifest(text);
386 const namespace =346 const namespace =
387 input.namespace ??347 input.namespace ??
@@ -447,25 +407,16 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 59 unmodified lines
447 const typedCtx = ctx as PluginCtx<GoogleDiscoveryStore>;407 const typedCtx = ctx as PluginCtx<GoogleDiscoveryStore>;
448 const scopes = new Set<string>();408 const scopes = new Set<string>();
449 for (const row of toolRows) scopes.add(row.scope_id as string);409 for (const row of toolRows) scopes.add(row.scope_id as string);
450 const byScope = new Map<410 const byScope = new Map<string, ReadonlyMap<string, GoogleDiscoveryMethodBinding>>();
451 string,
452 ReadonlyMap<string, GoogleDiscoveryMethodBinding>
453 >();
454 for (const scope of scopes) {411 for (const scope of scopes) {
455 const bindings = yield* typedCtx.storage.getBindingsForSource(412 const bindings = yield* typedCtx.storage.getBindingsForSource(sourceId, scope);
456 sourceId,
457 scope,
458 );
459 byScope.set(scope, bindings);413 byScope.set(scope, bindings);
460 }414 }
461 const out: Record<string, ToolAnnotations> = {};415 const out: Record<string, ToolAnnotations> = {};
462 for (const row of toolRows) {416 for (const row of toolRows) {
463 const binding = byScope.get(row.scope_id as string)?.get(row.id);417 const binding = byScope.get(row.scope_id as string)?.get(row.id);
464 if (binding) {418 if (binding) {
465 out[row.id] = annotationsForOperation(419 out[row.id] = annotationsForOperation(binding.method, binding.pathTemplate);
466 binding.method,
467 binding.pathTemplate,
468 );
469 }420 }
470 }421 }
471 return out;422 return out;
@@ -482,14 +433,11 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 10 unmodified lines
482 Effect.gen(function* () {433 Effect.gen(function* () {
483 const trimmed = url.trim();434 const trimmed = url.trim();
484 if (!trimmed) return null;435 if (!trimmed) return null;
485 const parsed = yield* Effect.try(() => new URL(trimmed)).pipe(436 const parsed = yield* Effect.try(() => new URL(trimmed)).pipe(Effect.option);
486 Effect.option,
487 );
488 if (parsed._tag === "None") return null;437 if (parsed._tag === "None") return null;
489 438
490 const isGoogleUrl = trimmed.includes("googleapis.com");439 const isGoogleUrl = trimmed.includes("googleapis.com");
491 const isDiscoveryPath =440 const isDiscoveryPath = trimmed.includes("/discovery/") || trimmed.includes("$discovery");
492 trimmed.includes("/discovery/") || trimmed.includes("$discovery");
493 if (!isGoogleUrl && !isDiscoveryPath) return null;441 if (!isGoogleUrl && !isDiscoveryPath) return null;
494 442
495 const discoveryText = yield* fetchDiscoveryDocument(trimmed).pipe(443 const discoveryText = yield* fetchDiscoveryDocument(trimmed).pipe(
@@ -497,9 +445,9 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 1 unmodified line
497 );445 );
498 if (!discoveryText) return null;446 if (!discoveryText) return null;
499 447
500 const manifest = yield* extractGoogleDiscoveryManifest(448 const manifest = yield* extractGoogleDiscoveryManifest(discoveryText).pipe(
501 discoveryText,449 Effect.catchAll(() => Effect.succeed(null)),
502 ).pipe(Effect.catchAll(() => Effect.succeed(null)));450 );
503 if (!manifest) return null;451 if (!manifest) return null;
504 452
505 const name = Option.getOrElse(453 const name = Option.getOrElse(
@@ -529,10 +477,7 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 23 unmodified lines
529 existing.config.credentials,477 existing.config.credentials,
530 typedCtx,478 typedCtx,
531 );479 );
532 const text = yield* fetchDiscoveryDocument(480 const text = yield* fetchDiscoveryDocument(existing.config.discoveryUrl, credentials);
533 existing.config.discoveryUrl,
534 credentials,
535 );
536 const manifest = yield* extractGoogleDiscoveryManifest(text);481 const manifest = yield* extractGoogleDiscoveryManifest(text);
537 const next = new GoogleDiscoveryStoredSourceDataSchema({482 const next = new GoogleDiscoveryStoredSourceDataSchema({
538 ...existing.config,483 ...existing.config,
@@ -542,11 +487,7 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 3 unmodified lines
542 servicePath: manifest.servicePath,487 servicePath: manifest.servicePath,
543 });488 });
544 yield* registerManifest(typedCtx, sourceId, scope, manifest, next);489 yield* registerManifest(typedCtx, sourceId, scope, manifest, next);
545 }).pipe(490 }).pipe(Effect.mapError((err) => (err instanceof Error ? err : new Error(String(err))))),
546 Effect.mapError((err) =>
547 err instanceof Error ? err : new Error(String(err)),
548 ),
549 ),
550 491
551 // Connection refresh is owned by the canonical `"oauth2"`492 // Connection refresh is owned by the canonical `"oauth2"`
552 // ConnectionProvider registered by core — no plugin-specific handler493 // ConnectionProvider registered by core — no plugin-specific handler
packages/plugins/google-discovery/src/sdk/types.ts+4−11
@@ -1,4 +1,5 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { SecretBackedValue } from "@executor/sdk";
2 3
3export const GoogleDiscoveryHttpMethod = Schema.Literal(4export const GoogleDiscoveryHttpMethod = Schema.Literal(
4 "get",5 "get",
@@ -90,15 +91,8 @@ export const GoogleDiscoveryAuth = Schema.Union( · 85 unmodified lines
90);91);
91export type GoogleDiscoveryAuth = typeof GoogleDiscoveryAuth.Type;92export type GoogleDiscoveryAuth = typeof GoogleDiscoveryAuth.Type;
92 93
93export const GoogleDiscoveryCredentialValue = Schema.Union(94export const GoogleDiscoveryCredentialValue = SecretBackedValue;
94 Schema.String,95export type GoogleDiscoveryCredentialValue = typeof GoogleDiscoveryCredentialValue.Type;
95 Schema.Struct({
96 secretId: Schema.String,
97 prefix: Schema.optional(Schema.String),
98 }),
99);
100export type GoogleDiscoveryCredentialValue =
101 typeof GoogleDiscoveryCredentialValue.Type;
102 96
103export const GoogleDiscoveryFetchCredentials = Schema.Struct({97export const GoogleDiscoveryFetchCredentials = Schema.Struct({
104 headers: Schema.optional(98 headers: Schema.optional(
@@ -108,8 +102,7 @@ export const GoogleDiscoveryFetchCredentials = Schema.Struct({ · 3 unmodified lines
108 Schema.Record({ key: Schema.String, value: GoogleDiscoveryCredentialValue }),102 Schema.Record({ key: Schema.String, value: GoogleDiscoveryCredentialValue }),
109 ),103 ),
110});104});
111export type GoogleDiscoveryFetchCredentials =105export type GoogleDiscoveryFetchCredentials = typeof GoogleDiscoveryFetchCredentials.Type;
112 typeof GoogleDiscoveryFetchCredentials.Type;
113 106
114export class GoogleDiscoveryStoredSourceData extends Schema.Class<GoogleDiscoveryStoredSourceData>(107export class GoogleDiscoveryStoredSourceData extends Schema.Class<GoogleDiscoveryStoredSourceData>(
115 "GoogleDiscoveryStoredSourceData",108 "GoogleDiscoveryStoredSourceData",
packages/plugins/graphql/src/sdk/invoke.ts+8−26
@@ -1,5 +1,6 @@
1import { Effect, Layer, Option } from "effect";1import { Effect, Layer, Option } from "effect";
2import { HttpClient, HttpClientRequest } from "@effect/platform";2import { HttpClient, HttpClientRequest } from "@effect/platform";
3import { resolveSecretBackedMap } from "@executor/sdk";
3 4
4import { GraphqlInvocationError } from "./errors";5import { GraphqlInvocationError } from "./errors";
5import { type HeaderValue, type OperationBinding, InvocationResult } from "./types";6import { type HeaderValue, type OperationBinding, InvocationResult } from "./types";
@@ -17,33 +18,14 @@ export const resolveHeaders = ( · 11 unmodified lines
17 (acc, [, value]) => (typeof value === "string" ? acc : acc + 1),18 (acc, [, value]) => (typeof value === "string" ? acc : acc + 1),
18 0,19 0,
19 );20 );
20 return Effect.gen(function* () {21 return resolveSecretBackedMap({
21 // Resolve secret-backed headers in parallel. Missing / failing22 values: headers,
22 // lookups drop the header rather than fail the invocation, same23 getSecret: (secretId) =>
23 // as the serial version.24 secrets.get(secretId).pipe(Effect.catchAll(() => Effect.succeed(null))),
24 const values = yield* Effect.all(25 missing: "drop",
25 entries.map(([name, value]) =>26 onMissing: () => undefined as never,
26 typeof value === "string"
27 ? Effect.succeed<{ readonly name: string; readonly value: string | null }>({
28 name,
29 value,
30 })
31 : secrets.get(value.secretId).pipe(
32 Effect.catchAll(() => Effect.succeed<string | null>(null)),
33 Effect.map((secret) => ({
34 name,
35 value: secret === null ? null : value.prefix ? `${value.prefix}${secret}` : secret,
36 })),
37 ),
38 ),
39 { concurrency: "unbounded" },
40 );
41 const resolved: Record<string, string> = {};
42 for (const { name, value } of values) {
43 if (value !== null) resolved[name] = value;
44 }
45 return resolved;
46 }).pipe(27 }).pipe(
28 Effect.map((resolved) => resolved ?? {}),
47 Effect.withSpan("plugin.graphql.secret.resolve", {29 Effect.withSpan("plugin.graphql.secret.resolve", {
48 attributes: {30 attributes: {
49 "plugin.graphql.headers.total": entries.length,31 "plugin.graphql.headers.total": entries.length,
packages/plugins/graphql/src/sdk/types.ts+14−37
@@ -1,4 +1,5 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { SecretBackedValue } from "@executor/sdk";
2 3
3// ---------------------------------------------------------------------------4// ---------------------------------------------------------------------------
4// GraphQL operation kind5// GraphQL operation kind
@@ -11,18 +12,14 @@ export type GraphqlOperationKind = typeof GraphqlOperationKind.Type; · 6 unmodified lines
11// Extracted field (becomes a tool)12// Extracted field (becomes a tool)
12// ---------------------------------------------------------------------------13// ---------------------------------------------------------------------------
13 14
14export class GraphqlArgument extends Schema.Class<GraphqlArgument>(15export class GraphqlArgument extends Schema.Class<GraphqlArgument>("GraphqlArgument")({
15 "GraphqlArgument",
16)({
17 name: Schema.String,16 name: Schema.String,
18 typeName: Schema.String,17 typeName: Schema.String,
19 required: Schema.Boolean,18 required: Schema.Boolean,
20 description: Schema.optionalWith(Schema.String, { as: "Option" }),19 description: Schema.optionalWith(Schema.String, { as: "Option" }),
21}) {}20}) {}
22 21
23export class ExtractedField extends Schema.Class<ExtractedField>(22export class ExtractedField extends Schema.Class<ExtractedField>("ExtractedField")({
24 "ExtractedField",
25)({
26 /** e.g. "user", "createUser" */23 /** e.g. "user", "createUser" */
27 fieldName: Schema.String,24 fieldName: Schema.String,
28 /** "query" or "mutation" */25 /** "query" or "mutation" */
@@ -35,9 +32,7 @@ export class ExtractedField extends Schema.Class<ExtractedField>( · 6 unmodified lines
35 returnTypeName: Schema.String,32 returnTypeName: Schema.String,
36}) {}33}) {}
37 34
38export class ExtractionResult extends Schema.Class<ExtractionResult>(35export class ExtractionResult extends Schema.Class<ExtractionResult>("ExtractionResult")({
39 "ExtractionResult",
40)({
41 /** Schema name from introspection */36 /** Schema name from introspection */
42 schemaName: Schema.optionalWith(Schema.String, { as: "Option" }),37 schemaName: Schema.optionalWith(Schema.String, { as: "Option" }),
43 fields: Schema.Array(ExtractedField),38 fields: Schema.Array(ExtractedField),
@@ -47,9 +42,7 @@ export class ExtractionResult extends Schema.Class<ExtractionResult>( · 3 unmodified lines
47// Operation binding — minimal data needed to invoke42// Operation binding — minimal data needed to invoke
48// ---------------------------------------------------------------------------43// ---------------------------------------------------------------------------
49 44
50export class OperationBinding extends Schema.Class<OperationBinding>(45export class OperationBinding extends Schema.Class<OperationBinding>("OperationBinding")({
51 "OperationBinding",
52)({
53 kind: GraphqlOperationKind,46 kind: GraphqlOperationKind,
54 fieldName: Schema.String,47 fieldName: Schema.String,
55 /** The full GraphQL query/mutation string */48 /** The full GraphQL query/mutation string */
@@ -62,13 +55,7 @@ export class OperationBinding extends Schema.Class<OperationBinding>( · 6 unmodified lines
62// Invocation55// Invocation
63// ---------------------------------------------------------------------------56// ---------------------------------------------------------------------------
64 57
65export const HeaderValue = Schema.Union(58export const HeaderValue = SecretBackedValue;
66 Schema.String,
67 Schema.Struct({
68 secretId: Schema.String,
69 prefix: Schema.optional(Schema.String),
70 }),
71);
72export type HeaderValue = typeof HeaderValue.Type;59export type HeaderValue = typeof HeaderValue.Type;
73export const QueryParamValue = HeaderValue;60export const QueryParamValue = HeaderValue;
74export type QueryParamValue = typeof QueryParamValue.Type;61export type QueryParamValue = typeof QueryParamValue.Type;
@@ -86,30 +73,20 @@ export const GraphqlSourceAuth = Schema.Union( · 11 unmodified lines
86);73);
87export type GraphqlSourceAuth = typeof GraphqlSourceAuth.Type;74export type GraphqlSourceAuth = typeof GraphqlSourceAuth.Type;
88 75
89export class InvocationConfig extends Schema.Class<InvocationConfig>(76export class InvocationConfig extends Schema.Class<InvocationConfig>("InvocationConfig")({
90 "InvocationConfig",
91)({
92 /** The GraphQL endpoint URL */77 /** The GraphQL endpoint URL */
93 endpoint: Schema.String,78 endpoint: Schema.String,
94 /** Headers applied to every request. Values can reference secrets. */79 /** Headers applied to every request. Values can reference secrets. */
95 headers: Schema.optionalWith(80 headers: Schema.optionalWith(Schema.Record({ key: Schema.String, value: HeaderValue }), {
96 Schema.Record({ key: Schema.String, value: HeaderValue }),81 default: () => ({}),
97 {82 }),
98 default: () => ({}),
99 },
100 ),
101 /** Query parameters applied to every request. Values can reference secrets. */83 /** Query parameters applied to every request. Values can reference secrets. */
102 queryParams: Schema.optionalWith(84 queryParams: Schema.optionalWith(Schema.Record({ key: Schema.String, value: QueryParamValue }), {
103 Schema.Record({ key: Schema.String, value: QueryParamValue }),85 default: () => ({}),
104 {86 }),
105 default: () => ({}),
106 },
107 ),
108}) {}87}) {}
109 88
110export class InvocationResult extends Schema.Class<InvocationResult>(89export class InvocationResult extends Schema.Class<InvocationResult>("InvocationResult")({
111 "InvocationResult",
112)({
113 status: Schema.Number,90 status: Schema.Number,
114 data: Schema.NullOr(Schema.Unknown),91 data: Schema.NullOr(Schema.Unknown),
115 errors: Schema.NullOr(Schema.Unknown),92 errors: Schema.NullOr(Schema.Unknown),
packages/plugins/mcp/src/api/group.ts+7−16
@@ -1,12 +1,9 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId } from "@executor/sdk";3import { ScopeId, SecretBackedMap } from "@executor/sdk";
4import { InternalError } from "@executor/api";4import { InternalError } from "@executor/api";
5 5
6import {6import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors";
7 McpConnectionError,
8 McpToolDiscoveryError,
9} from "../sdk/errors";
10import { McpStoredSourceSchema } from "../sdk/stored-source";7import { McpStoredSourceSchema } from "../sdk/stored-source";
11 8
12// Re-export for handler use9// Re-export for handler use
@@ -43,15 +40,6 @@ const AuthPayload = Schema.Union( · 30 unmodified lines
43);40);
44 41
45const StringMap = Schema.Record({ key: Schema.String, value: Schema.String });42const StringMap = Schema.Record({ key: Schema.String, value: Schema.String });
46const SecretBackedValue = Schema.Union(
47 Schema.String,
48 Schema.Struct({
49 secretId: Schema.String,
50 prefix: Schema.optional(Schema.String),
51 }),
52);
53const SecretBackedMap = Schema.Record({ key: Schema.String, value: SecretBackedValue });
54
55// ---------------------------------------------------------------------------43// ---------------------------------------------------------------------------
56// Add source — discriminated union on transport44// Add source — discriminated union on transport
57// ---------------------------------------------------------------------------45// ---------------------------------------------------------------------------
@@ -169,8 +157,11 @@ export class McpGroup extends HttpApiGroup.make("mcp") · 111 unmodified lines
169 .addSuccess(RefreshSourceResponse),157 .addSuccess(RefreshSourceResponse),
170 )158 )
171 .add(159 .add(
172 HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}`160 HttpApiEndpoint.get(
173 .addSuccess(Schema.NullOr(McpStoredSourceSchema)),161 "getSource",
162 )`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}`.addSuccess(
163 Schema.NullOr(McpStoredSourceSchema),
164 ),
174 )165 )
175 .add(166 .add(
176 HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}`167 HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}`
packages/plugins/mcp/src/sdk/plugin.ts+521−587
@@ -5,6 +5,7 @@ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth. · 4 unmodified lines
5import {5import {
6 SourceDetectionResult,6 SourceDetectionResult,
7 definePlugin,7 definePlugin,
8 resolveSecretBackedMap as resolveSharedSecretBackedMap,
8 type PluginCtx,9 type PluginCtx,
9 type StorageFailure,10 type StorageFailure,
10} from "@executor/sdk";11} from "@executor/sdk";
@@ -15,21 +16,11 @@ import { · 4 unmodified lines
15 type McpBindingStore,16 type McpBindingStore,
16 type McpStoredSource,17 type McpStoredSource,
17} from "./binding-store";18} from "./binding-store";
18import {19import { createMcpConnector, type ConnectorInput, type McpConnection } from "./connection";
19 createMcpConnector,
20 type ConnectorInput,
21 type McpConnection,
22} from "./connection";
23import { discoverTools } from "./discover";20import { discoverTools } from "./discover";
24import {21import { McpConnectionError, McpToolDiscoveryError } from "./errors";
25 McpConnectionError,
26 McpToolDiscoveryError,
27} from "./errors";
28import { invokeMcpTool } from "./invoke";22import { invokeMcpTool } from "./invoke";
29import {23import { deriveMcpNamespace, type McpToolManifestEntry } from "./manifest";
30 deriveMcpNamespace,
31 type McpToolManifestEntry,
32} from "./manifest";
33import { probeMcpEndpointShape } from "./probe-shape";24import { probeMcpEndpointShape } from "./probe-shape";
34import {25import {
35 McpToolBinding,26 McpToolBinding,
@@ -200,38 +191,26 @@ const remoteConnectionError = (message: string) => · 164 unmodified lines
200const mcpDiscoveryError = (message: string) =>191const mcpDiscoveryError = (message: string) =>
201 new McpToolDiscoveryError({ stage: "list_tools", message });192 new McpToolDiscoveryError({ stage: "list_tools", message });
202 193
203const resolveSecretBackedValue = (194const resolveSecretBackedMap = (
204 value: SecretBackedValue,195 values: Record<string, SecretBackedValue> | undefined,
205 ctx: PluginCtx<McpBindingStore>,196 ctx: PluginCtx<McpBindingStore>,
206): Effect.Effect<string, McpConnectionError | StorageFailure> => {197): Effect.Effect<Record<string, string> | undefined, McpConnectionError | StorageFailure> =>
207 if (typeof value === "string") return Effect.succeed(value);198 resolveSharedSecretBackedMap({
208 return ctx.secrets.get(value.secretId).pipe(199 values,
209 Effect.mapError((err) =>200 getSecret: ctx.secrets.get,
201 onMissing: (_name, value) =>
202 remoteConnectionError(`Failed to resolve secret "${value.secretId}"`),
203 onError: (err, _name, value) =>
210 "_tag" in err && err._tag === "SecretOwnedByConnectionError"204 "_tag" in err && err._tag === "SecretOwnedByConnectionError"
211 ? remoteConnectionError(`Failed to resolve secret "${value.secretId}"`)205 ? remoteConnectionError(`Failed to resolve secret "${value.secretId}"`)
212 : err,206 : err,
213 ),207 }).pipe(
214 Effect.flatMap((secret) =>208 Effect.mapError((err) =>
215 secret === null209 "_tag" in err && err._tag === "SecretOwnedByConnectionError"
216 ? Effect.fail(remoteConnectionError(`Failed to resolve secret "${value.secretId}"`))210 ? remoteConnectionError("Failed to resolve secret")
217 : Effect.succeed(value.prefix ? `${value.prefix}${secret}` : secret),211 : err,
218 ),
219 );
220};
221
222const resolveSecretBackedMap = (
223 values: Record<string, SecretBackedValue> | undefined,
224 ctx: PluginCtx<McpBindingStore>,
225): Effect.Effect<Record<string, string> | undefined, McpConnectionError | StorageFailure> => {
226 if (!values) return Effect.succeed(undefined);
227 return Effect.forEach(Object.entries(values), ([key, value]) =>
228 Effect.map(resolveSecretBackedValue(value, ctx), (resolved) => [key, resolved] as const),
229 ).pipe(
230 Effect.map((entries) =>
231 entries.length > 0 ? Object.fromEntries(entries) : undefined,
232 ),212 ),
233 );213 );
234};
235 214
236const plainStringMap = (215const plainStringMap = (
237 values: Record<string, SecretBackedValue> | undefined,216 values: Record<string, SecretBackedValue> | undefined,
@@ -279,13 +258,15 @@ const resolveConnectorInput = ( · 41 unmodified lines
279 258
280 const auth = sd.auth;259 const auth = sd.auth;
281 if (auth.kind === "header") {260 if (auth.kind === "header") {
282 const val = yield* ctx.secrets.get(auth.secretId).pipe(261 const val = yield* ctx.secrets
283 Effect.mapError((err) =>262 .get(auth.secretId)
284 "_tag" in err && err._tag === "SecretOwnedByConnectionError"263 .pipe(
285 ? remoteConnectionError(`Failed to resolve secret "${auth.secretId}"`)264 Effect.mapError((err) =>
286 : err,265 "_tag" in err && err._tag === "SecretOwnedByConnectionError"
287 ),266 ? remoteConnectionError(`Failed to resolve secret "${auth.secretId}"`)
288 );267 : err,
268 ),
269 );
289 if (val === null) {270 if (val === null) {
290 return yield* Effect.fail(271 return yield* Effect.fail(
291 remoteConnectionError(`Failed to resolve secret "${auth.secretId}"`),272 remoteConnectionError(`Failed to resolve secret "${auth.secretId}"`),
@@ -304,9 +285,7 @@ const resolveConnectorInput = ( · 12 unmodified lines
304 Effect.mapError((err) =>285 Effect.mapError((err) =>
305 remoteConnectionError(286 remoteConnectionError(
306 `Failed to resolve OAuth connection "${auth.connectionId}": ${287 `Failed to resolve OAuth connection "${auth.connectionId}": ${
307 "message" in err288 "message" in err ? (err as { message: string }).message : String(err)
308 ? (err as { message: string }).message
309 : String(err)
310 }`,289 }`,
311 ),290 ),
312 ),291 ),
@@ -332,25 +311,15 @@ const resolveConnectorInput = ( · 19 unmodified lines
332// ---------------------------------------------------------------------------311// ---------------------------------------------------------------------------
333 312
334interface McpRuntime {313interface McpRuntime {
335 readonly connectionCache: ScopedCache.ScopedCache<314 readonly connectionCache: ScopedCache.ScopedCache<string, McpConnection, McpConnectionError>;
336 string,315 readonly pendingConnectors: Map<string, Effect.Effect<McpConnection, McpConnectionError>>;
337 McpConnection,
338 McpConnectionError
339 >;
340 readonly pendingConnectors: Map<
341 string,
342 Effect.Effect<McpConnection, McpConnectionError>
343 >;
344 readonly cacheScope: Scope.CloseableScope;316 readonly cacheScope: Scope.CloseableScope;
345}317}
346 318
347const makeRuntime = (): Effect.Effect<McpRuntime, never> =>319const makeRuntime = (): Effect.Effect<McpRuntime, never> =>
348 Effect.gen(function* () {320 Effect.gen(function* () {
349 const cacheScope = yield* Scope.make();321 const cacheScope = yield* Scope.make();
350 const pendingConnectors = new Map<322 const pendingConnectors = new Map<string, Effect.Effect<McpConnection, McpConnectionError>>();
351 string,
352 Effect.Effect<McpConnection, McpConnectionError>
353 >();
354 const connectionCache = yield* ScopedCache.make({323 const connectionCache = yield* ScopedCache.make({
355 lookup: (key: string) =>324 lookup: (key: string) =>
356 Effect.acquireRelease(325 Effect.acquireRelease(
@@ -366,8 +335,7 @@ const makeRuntime = (): Effect.Effect<McpRuntime, never> => · 9 unmodified lines
366 }335 }
367 return connector;336 return connector;
368 }),337 }),
369 (connection) =>338 (connection) => Effect.promise(() => connection.close().catch(() => {})),
370 Effect.promise(() => connection.close().catch(() => {})),
371 ),339 ),
372 capacity: 64,340 capacity: 64,
373 timeToLive: Duration.minutes(5),341 timeToLive: Duration.minutes(5),
@@ -444,540 +412,484 @@ const toMcpConfigEntry = ( · 70 unmodified lines
444 return entry;412 return entry;
445};413};
446 414
447export const mcpPlugin = definePlugin(415export const mcpPlugin = definePlugin((options?: McpPluginOptions) => {
448 (options?: McpPluginOptions) => {416 const allowStdio = options?.dangerouslyAllowStdioMCP ?? false;
449 const allowStdio = options?.dangerouslyAllowStdioMCP ?? false;417 // Per-plugin-instance runtime holder. Captured by closures in
450 // Per-plugin-instance runtime holder. Captured by closures in418 // `extension`, `invokeTool`, and `close`, so all three see the same
451 // `extension`, `invokeTool`, and `close`, so all three see the same419 // connection cache across a single createExecutor lifecycle.
452 // connection cache across a single createExecutor lifecycle.420 const runtimeRef: { current: McpRuntime | null } = { current: null };
453 const runtimeRef: { current: McpRuntime | null } = { current: null };421
454 422 const ensureRuntime = (): Effect.Effect<McpRuntime, never> =>
455 const ensureRuntime = (): Effect.Effect<McpRuntime, never> =>423 runtimeRef.current
456 runtimeRef.current424 ? Effect.succeed(runtimeRef.current)
457 ? Effect.succeed(runtimeRef.current)425 : makeRuntime().pipe(
458 : makeRuntime().pipe(426 Effect.tap((rt) =>
459 Effect.tap((rt) =>427 Effect.sync(() => {
460 Effect.sync(() => {428 runtimeRef.current = rt;
461 runtimeRef.current = rt;429 }),
462 }),430 ),
463 ),431 );
464 );
465 432
466 return {433 return {
467 id: "mcp" as const,434 id: "mcp" as const,
468 schema: mcpSchema,435 schema: mcpSchema,
469 storage: (deps): McpBindingStore => makeMcpStore(deps),436 storage: (deps): McpBindingStore => makeMcpStore(deps),
470
471 extension: (ctx) => {
472 const probeEndpoint = (input: string | McpProbeEndpointInput) =>
473 Effect.gen(function* () {
474 const endpoint = typeof input === "string" ? input : input.endpoint;
475 const trimmed = endpoint.trim();
476 if (!trimmed) {
477 return yield* Effect.fail(
478 remoteConnectionError("Endpoint URL is required"),
479 );
480 }
481 437
482 const name = yield* Effect.try(438 extension: (ctx) => {
483 () => new URL(trimmed).hostname,439 const probeEndpoint = (input: string | McpProbeEndpointInput) =>
484 ).pipe(Effect.orElseSucceed(() => "mcp"));440 Effect.gen(function* () {
485 const namespace = deriveMcpNamespace({ endpoint: trimmed });441 const endpoint = typeof input === "string" ? input : input.endpoint;
486 442 const trimmed = endpoint.trim();
487 const probeHeaders =443 if (!trimmed) {
488 typeof input === "string"444 return yield* Effect.fail(remoteConnectionError("Endpoint URL is required"));
489 ? undefined445 }
490 : yield* resolveSecretBackedMap(input.headers, ctx);
491 const probeQueryParams =
492 typeof input === "string"
493 ? undefined
494 : yield* resolveSecretBackedMap(input.queryParams, ctx);
495
496 const connector = createMcpConnector({
497 transport: "remote",
498 endpoint: trimmed,
499 headers: probeHeaders,
500 queryParams: probeQueryParams,
501 });
502 446
503 const result = yield* discoverTools(connector).pipe(447 const name = yield* Effect.try(() => new URL(trimmed).hostname).pipe(
504 Effect.map((m) => ({ ok: true as const, manifest: m })),448 Effect.orElseSucceed(() => "mcp"),
505 Effect.catchAll(() =>449 );
506 Effect.succeed({ ok: false as const, manifest: null }),450 const namespace = deriveMcpNamespace({ endpoint: trimmed });
507 ),
508 Effect.withSpan("mcp.plugin.discover_tools"),
509 );
510 451
511 if (result.ok && result.manifest) {452 const probeHeaders =
512 return {453 typeof input === "string"
513 connected: true,454 ? undefined
514 requiresOAuth: false,455 : yield* resolveSecretBackedMap(input.headers, ctx);
515 name: result.manifest.server?.name ?? name,456 const probeQueryParams =
516 namespace,457 typeof input === "string"
517 toolCount: result.manifest.tools.length,458 ? undefined
518 serverName: result.manifest.server?.name ?? null,459 : yield* resolveSecretBackedMap(input.queryParams, ctx);
519 } satisfies McpProbeResult;
520 }
521 460
522 // Before asking the core OAuth service to look for metadata,461 const connector = createMcpConnector({
523 // confirm the endpoint actually speaks MCP. An OAuth-protected462 transport: "remote",
524 // non-MCP service (e.g. a GraphQL API whose host publishes463 endpoint: trimmed,
525 // RFC 9728 + 8414 metadata) would otherwise pass the OAuth464 headers: probeHeaders,
526 // probe and be misclassified as MCP. The shape probe rejects465 queryParams: probeQueryParams,
527 // anything whose initialize response isn't 2xx or 401+Bearer.466 });
528 const shape = yield* probeMcpEndpointShape(trimmed, {
529 headers: probeHeaders,
530 queryParams: probeQueryParams,
531 });
532 if (shape.kind !== "mcp") {
533 return yield* Effect.fail(
534 remoteConnectionError(
535 shape.kind === "not-mcp"
536 ? `Endpoint does not look like an MCP server: ${shape.reason}`
537 : `Could not reach endpoint: ${shape.reason}`,
538 ),
539 );
540 }
541 467
542 const probeResult = yield* ctx.oauth468 const result = yield* discoverTools(connector).pipe(
543 .probe({469 Effect.map((m) => ({ ok: true as const, manifest: m })),
544 endpoint: trimmed,470 Effect.catchAll(() => Effect.succeed({ ok: false as const, manifest: null })),
545 headers: probeHeaders,471 Effect.withSpan("mcp.plugin.discover_tools"),
546 queryParams: probeQueryParams,472 );
547 })
548 .pipe(
549 Effect.map(() => true),
550 Effect.catchAll(() => Effect.succeed(false)),
551 Effect.withSpan("mcp.plugin.probe_oauth"),
552 );
553 473
554 if (probeResult) {474 if (result.ok && result.manifest) {
555 return {475 return {
556 connected: false,476 connected: true,
557 requiresOAuth: true,477 requiresOAuth: false,
558 name,478 name: result.manifest.server?.name ?? name,
559 namespace,479 namespace,
560 toolCount: null,480 toolCount: result.manifest.tools.length,
561 serverName: null,481 serverName: result.manifest.server?.name ?? null,
562 } satisfies McpProbeResult;482 } satisfies McpProbeResult;
563 }483 }
564 484
485 // Before asking the core OAuth service to look for metadata,
486 // confirm the endpoint actually speaks MCP. An OAuth-protected
487 // non-MCP service (e.g. a GraphQL API whose host publishes
488 // RFC 9728 + 8414 metadata) would otherwise pass the OAuth
489 // probe and be misclassified as MCP. The shape probe rejects
490 // anything whose initialize response isn't 2xx or 401+Bearer.
491 const shape = yield* probeMcpEndpointShape(trimmed, {
492 headers: probeHeaders,
493 queryParams: probeQueryParams,
494 });
495 if (shape.kind !== "mcp") {
565 return yield* Effect.fail(496 return yield* Effect.fail(
566 remoteConnectionError(497 remoteConnectionError(
567 "MCP server requires authentication but OAuth discovery failed",498 shape.kind === "not-mcp"
499 ? `Endpoint does not look like an MCP server: ${shape.reason}`
500 : `Could not reach endpoint: ${shape.reason}`,
568 ),501 ),
569 );502 );
570 }).pipe(503 }
571 Effect.withSpan("mcp.plugin.probe_endpoint", {
572 attributes: { "mcp.endpoint": typeof input === "string" ? input : input.endpoint },
573 }),
574 );
575 504
576 const configFile = options?.configFile;505 const probeResult = yield* ctx.oauth
577 506 .probe({
578 const addSource = (config: McpSourceConfig) =>507 endpoint: trimmed,
579 Effect.gen(function* () {508 headers: probeHeaders,
580 const namespace = normalizeNamespace(config);509 queryParams: probeQueryParams,
581 const sd = toStoredSourceData(config);510 })
582 511 .pipe(
583 // Stdio sources are gated — a resolver failure there is a512 Effect.map(() => true),
584 // config error the admin must fix before the source makes513 Effect.catchAll(() => Effect.succeed(false)),
585 // sense to persist at all. For remote sources we defer the514 Effect.withSpan("mcp.plugin.probe_oauth"),
586 // resolver failure: auth might not be ready yet (oauth2
587 // connection awaiting per-user sign-in, header secret
588 // awaiting upload) but the source row should still land so
589 // it shows up in the list and exposes a Sign-in affordance.
590 const resolved = yield* resolveConnectorInput(sd, ctx, allowStdio).pipe(
591 Effect.either,
592 Effect.withSpan("mcp.plugin.resolve_connector", {
593 attributes: {
594 "mcp.source.namespace": namespace,
595 "mcp.source.transport": sd.transport,
596 },
597 }),
598 );515 );
599 516
600 if (resolved._tag === "Left" && sd.transport === "stdio") {517 if (probeResult) {
601 return yield* Effect.fail(resolved.left);518 return {
602 }519 connected: false,
520 requiresOAuth: true,
521 name,
522 namespace,
523 toolCount: null,
524 serverName: null,
525 } satisfies McpProbeResult;
526 }
603 527
604 // Try discovery only if we have a live connector input.528 return yield* Effect.fail(
605 // Otherwise fall straight through to the persist step with529 remoteConnectionError("MCP server requires authentication but OAuth discovery failed"),
606 // an empty manifest and surface the resolver failure to530 );
607 // the caller at the end.531 }).pipe(
608 const discovery =532 Effect.withSpan("mcp.plugin.probe_endpoint", {
609 resolved._tag === "Right"533 attributes: { "mcp.endpoint": typeof input === "string" ? input : input.endpoint },
610 ? yield* discoverTools(createMcpConnector(resolved.right)).pipe(534 }),
611 Effect.mapError((err) =>535 );
612 mcpDiscoveryError(`MCP discovery failed: ${err.message}`),
613 ),
614 Effect.either,
615 Effect.withSpan("mcp.plugin.discover_tools", {
616 attributes: { "mcp.source.namespace": namespace },
617 }),
618 )
619 : ({ _tag: "Left", left: resolved.left } as const);
620 const manifest =
621 discovery._tag === "Right"
622 ? discovery.right
623 : { server: undefined, tools: [] as const };
624
625 const sourceName = config.name ?? manifest.server?.name ?? namespace;
626
627 yield* ctx
628 .transaction(
629 Effect.gen(function* () {
630 // Remove stale rows at the target scope (plugin-owned).
631 // Pinning scope keeps a shadowed outer-scope row intact
632 // when a per-user addSource re-uses the same namespace.
633 yield* ctx.storage.removeBindingsByNamespace(
634 namespace,
635 config.scope,
636 );
637 yield* ctx.storage.removeSource(namespace, config.scope);
638
639 yield* ctx.storage.putSource({
640 namespace,
641 scope: config.scope,
642 name: sourceName,
643 config: sd,
644 });
645
646 yield* ctx.storage.putBindings(
647 namespace,
648 config.scope,
649 manifest.tools.map((e) => ({
650 toolId: `${namespace}.${e.toolId}`,
651 binding: toBinding(e),
652 })),
653 );
654
655 yield* ctx.core.sources.register({
656 id: namespace,
657 scope: config.scope,
658 kind: "mcp",
659 name: sourceName,
660 url: sd.transport === "remote" ? sd.endpoint : undefined,
661 canRemove: true,
662 canRefresh: true,
663 canEdit: sd.transport === "remote",
664 tools: manifest.tools.map((e) => ({
665 name: e.toolId,
666 description: e.description ?? `MCP tool: ${e.toolName}`,
667 inputSchema: e.inputSchema,
668 outputSchema: e.outputSchema,
669 })),
670 });
671 }),
672 )
673 .pipe(
674 Effect.withSpan("mcp.plugin.persist_source", {
675 attributes: {
676 "mcp.source.namespace": namespace,
677 "mcp.source.tool_count": manifest.tools.length,
678 },
679 }),
680 );
681 536
682 if (configFile) {537 const configFile = options?.configFile;
683 yield* configFile
684 .upsertSource(toMcpConfigEntry(namespace, sourceName, config))
685 .pipe(Effect.withSpan("mcp.plugin.config_file.upsert"));
686 }
687 538
688 if (discovery._tag === "Left") {539 const addSource = (config: McpSourceConfig) =>
689 return yield* Effect.fail(discovery.left);540 Effect.gen(function* () {
690 }541 const namespace = normalizeNamespace(config);
691 return { toolCount: manifest.tools.length, namespace };542 const sd = toStoredSourceData(config);
692 }).pipe(543
693 Effect.withSpan("mcp.plugin.add_source", {544 // Stdio sources are gated — a resolver failure there is a
545 // config error the admin must fix before the source makes
546 // sense to persist at all. For remote sources we defer the
547 // resolver failure: auth might not be ready yet (oauth2
548 // connection awaiting per-user sign-in, header secret
549 // awaiting upload) but the source row should still land so
550 // it shows up in the list and exposes a Sign-in affordance.
551 const resolved = yield* resolveConnectorInput(sd, ctx, allowStdio).pipe(
552 Effect.either,
553 Effect.withSpan("mcp.plugin.resolve_connector", {
694 attributes: {554 attributes: {
695 "mcp.source.transport": config.transport,555 "mcp.source.namespace": namespace,
696 "mcp.source.name": config.name,556 "mcp.source.transport": sd.transport,
697 },557 },
698 }),558 }),
699 );559 );
700 560
701 const removeSource = (namespace: string, scope: string) =>561 if (resolved._tag === "Left" && sd.transport === "stdio") {
702 Effect.gen(function* () {562 return yield* Effect.fail(resolved.left);
703 yield* ctx563 }
704 .transaction(
705 Effect.gen(function* () {
706 yield* ctx.storage.removeBindingsByNamespace(namespace, scope);
707 yield* ctx.storage.removeSource(namespace, scope);
708 yield* ctx.core.sources.unregister(namespace);
709 }),
710 )
711 .pipe(Effect.withSpan("mcp.plugin.persist_remove"));
712 if (configFile) {
713 yield* configFile
714 .removeSource(namespace)
715 .pipe(Effect.withSpan("mcp.plugin.config_file.remove"));
716 }
717 }).pipe(
718 Effect.withSpan("mcp.plugin.remove_source", {
719 attributes: { "mcp.source.namespace": namespace },
720 }),
721 );
722 564
723 const refreshSource = (namespace: string, scope: string) =>565 // Try discovery only if we have a live connector input.
724 Effect.gen(function* () {566 // Otherwise fall straight through to the persist step with
725 const sd = yield* ctx.storage.getSourceConfig(namespace, scope).pipe(567 // an empty manifest and surface the resolver failure to
726 Effect.withSpan("mcp.plugin.load_source_config", {568 // the caller at the end.
727 attributes: { "mcp.source.namespace": namespace },569 const discovery =
570 resolved._tag === "Right"
571 ? yield* discoverTools(createMcpConnector(resolved.right)).pipe(
572 Effect.mapError((err) =>
573 mcpDiscoveryError(`MCP discovery failed: ${err.message}`),
574 ),
575 Effect.either,
576 Effect.withSpan("mcp.plugin.discover_tools", {
577 attributes: { "mcp.source.namespace": namespace },
578 }),
579 )
580 : ({ _tag: "Left", left: resolved.left } as const);
581 const manifest =
582 discovery._tag === "Right"
583 ? discovery.right
584 : { server: undefined, tools: [] as const };
585
586 const sourceName = config.name ?? manifest.server?.name ?? namespace;
587
588 yield* ctx
589 .transaction(
590 Effect.gen(function* () {
591 // Remove stale rows at the target scope (plugin-owned).
592 // Pinning scope keeps a shadowed outer-scope row intact
593 // when a per-user addSource re-uses the same namespace.
594 yield* ctx.storage.removeBindingsByNamespace(namespace, config.scope);
595 yield* ctx.storage.removeSource(namespace, config.scope);
596
597 yield* ctx.storage.putSource({
598 namespace,
599 scope: config.scope,
600 name: sourceName,
601 config: sd,
602 });
603
604 yield* ctx.storage.putBindings(
605 namespace,
606 config.scope,
607 manifest.tools.map((e) => ({
608 toolId: `${namespace}.${e.toolId}`,
609 binding: toBinding(e),
610 })),
611 );
612
613 yield* ctx.core.sources.register({
614 id: namespace,
615 scope: config.scope,
616 kind: "mcp",
617 name: sourceName,
618 url: sd.transport === "remote" ? sd.endpoint : undefined,
619 canRemove: true,
620 canRefresh: true,
621 canEdit: sd.transport === "remote",
622 tools: manifest.tools.map((e) => ({
623 name: e.toolId,
624 description: e.description ?? `MCP tool: ${e.toolName}`,
625 inputSchema: e.inputSchema,
626 outputSchema: e.outputSchema,
627 })),
628 });
728 }),629 }),
729 );630 )
730 if (!sd) {631 .pipe(
731 return yield* Effect.fail(632 Effect.withSpan("mcp.plugin.persist_source", {
732 remoteConnectionError(
733 `No stored config for MCP source "${namespace}"`,
734 ),
735 );
736 }
737
738 const ci = yield* resolveConnectorInput(sd, ctx, allowStdio).pipe(
739 Effect.withSpan("mcp.plugin.resolve_connector", {
740 attributes: {633 attributes: {
741 "mcp.source.namespace": namespace,634 "mcp.source.namespace": namespace,
742 "mcp.source.transport": sd.transport,635 "mcp.source.tool_count": manifest.tools.length,
743 },636 },
744 }),637 }),
745 );638 );
746 const manifest = yield* discoverTools(createMcpConnector(ci)).pipe(
747 Effect.mapError((err) =>
748 mcpDiscoveryError(`MCP refresh failed: ${err.message}`),
749 ),
750 Effect.withSpan("mcp.plugin.discover_tools", {
751 attributes: { "mcp.source.namespace": namespace },
752 }),
753 );
754 639
755 const existing = yield* ctx.storage.getSource(namespace, scope);640 if (configFile) {
756 const sourceName =641 yield* configFile
757 manifest.server?.name ?? existing?.name ?? namespace;642 .upsertSource(toMcpConfigEntry(namespace, sourceName, config))
758 643 .pipe(Effect.withSpan("mcp.plugin.config_file.upsert"));
759 yield* ctx644 }
760 .transaction(
761 Effect.gen(function* () {
762 yield* ctx.storage.removeBindingsByNamespace(namespace, scope);
763 yield* ctx.core.sources.unregister(namespace);
764
765 yield* ctx.storage.putBindings(
766 namespace,
767 scope,
768 manifest.tools.map((e) => ({
769 toolId: `${namespace}.${e.toolId}`,
770 binding: toBinding(e),
771 })),
772 );
773 yield* ctx.core.sources.register({
774 id: namespace,
775 scope,
776 kind: "mcp",
777 name: sourceName,
778 url: sd.transport === "remote" ? sd.endpoint : undefined,
779 canRemove: true,
780 canRefresh: true,
781 canEdit: sd.transport === "remote",
782 tools: manifest.tools.map((e) => ({
783 name: e.toolId,
784 description: e.description ?? `MCP tool: ${e.toolName}`,
785 inputSchema: e.inputSchema,
786 outputSchema: e.outputSchema,
787 })),
788 });
789 }),
790 )
791 .pipe(
792 Effect.withSpan("mcp.plugin.persist_source", {
793 attributes: {
794 "mcp.source.namespace": namespace,
795 "mcp.source.tool_count": manifest.tools.length,
796 },
797 }),
798 );
799
800 return { toolCount: manifest.tools.length };
801 }).pipe(
802 Effect.withSpan("mcp.plugin.refresh_source", {
803 attributes: { "mcp.source.namespace": namespace },
804 }),
805 );
806 645
807 const updateSource = (646 if (discovery._tag === "Left") {
808 namespace: string,647 return yield* Effect.fail(discovery.left);
809 scope: string,648 }
810 input: McpUpdateSourceInput,649 return { toolCount: manifest.tools.length, namespace };
811 ) =>650 }).pipe(
812 Effect.gen(function* () {651 Effect.withSpan("mcp.plugin.add_source", {
813 const existing = yield* ctx.storage.getSource(namespace, scope);652 attributes: {
814 if (!existing || existing.config.transport !== "remote") return;653 "mcp.source.transport": config.transport,
815 654 "mcp.source.name": config.name,
816 const remote = existing.config;655 },
817 const updatedConfig: McpStoredSourceData = {656 }),
818 ...remote,657 );
819 ...(input.endpoint !== undefined ? { endpoint: input.endpoint } : {}),
820 ...(input.headers !== undefined ? { headers: input.headers } : {}),
821 ...(input.auth !== undefined ? { auth: input.auth } : {}),
822 ...(input.queryParams !== undefined
823 ? { queryParams: input.queryParams }
824 : {}),
825 };
826
827 yield* ctx.storage.putSource({
828 namespace,
829 scope,
830 name: input.name?.trim() || existing.name,
831 config: updatedConfig,
832 });
833 }).pipe(
834 Effect.withSpan("mcp.plugin.update_source", {
835 attributes: { "mcp.source.namespace": namespace },
836 }),
837 );
838 658
839 const getSource = (namespace: string, scope: string) =>659 const removeSource = (namespace: string, scope: string) =>
840 ctx.storage.getSource(namespace, scope).pipe(660 Effect.gen(function* () {
841 Effect.withSpan("mcp.plugin.get_source", {661 yield* ctx
842 attributes: { "mcp.source.namespace": namespace },662 .transaction(
843 }),663 Effect.gen(function* () {
844 );664 yield* ctx.storage.removeBindingsByNamespace(namespace, scope);
665 yield* ctx.storage.removeSource(namespace, scope);
666 yield* ctx.core.sources.unregister(namespace);
667 }),
668 )
669 .pipe(Effect.withSpan("mcp.plugin.persist_remove"));
670 if (configFile) {
671 yield* configFile
672 .removeSource(namespace)
673 .pipe(Effect.withSpan("mcp.plugin.config_file.remove"));
674 }
675 }).pipe(
676 Effect.withSpan("mcp.plugin.remove_source", {
677 attributes: { "mcp.source.namespace": namespace },
678 }),
679 );
845 680
846 return {681 const refreshSource = (namespace: string, scope: string) =>
847 probeEndpoint,
848 addSource,
849 removeSource,
850 refreshSource,
851 getSource,
852 updateSource,
853 } satisfies McpPluginExtension;
854 },
855
856 invokeTool: ({ ctx, toolRow, args, elicit }) =>
857 Effect.gen(function* () {682 Effect.gen(function* () {
858 const runtime = yield* ensureRuntime();683 const sd = yield* ctx.storage.getSourceConfig(namespace, scope).pipe(
859 684 Effect.withSpan("mcp.plugin.load_source_config", {
860 // toolRow.scope_id is the resolved owning scope of the tool685 attributes: { "mcp.source.namespace": namespace },
861 // (innermost-wins from the executor's stack). The matching
862 // mcp_binding + mcp_source rows live at the same scope, so
863 // pin every store lookup to it instead of relying on the
864 // scoped adapter's stack-wide fall-through.
865 const toolScope = toolRow.scope_id as string;
866 const entry = yield* ctx.storage.getBinding(toolRow.id, toolScope).pipe(
867 Effect.withSpan("mcp.plugin.load_binding", {
868 attributes: { "mcp.tool.name": toolRow.id },
869 }),686 }),
870 );687 );
871 if (!entry) {688 if (!sd) {
872 return yield* Effect.fail(689 return yield* Effect.fail(
873 new Error(`No MCP binding found for tool "${toolRow.id}"`),690 remoteConnectionError(`No stored config for MCP source "${namespace}"`),
874 );691 );
875 }692 }
876 693
877 const sd = yield* ctx.storage.getSourceConfig(entry.namespace, toolScope).pipe(694 const ci = yield* resolveConnectorInput(sd, ctx, allowStdio).pipe(
878 Effect.withSpan("mcp.plugin.load_source_config", {695 Effect.withSpan("mcp.plugin.resolve_connector", {
879 attributes: { "mcp.source.namespace": entry.namespace },696 attributes: {
697 "mcp.source.namespace": namespace,
698 "mcp.source.transport": sd.transport,
699 },
880 }),700 }),
881 );701 );
882 if (!sd) {702 const manifest = yield* discoverTools(createMcpConnector(ci)).pipe(
883 return yield* Effect.fail(703 Effect.mapError((err) => mcpDiscoveryError(`MCP refresh failed: ${err.message}`)),
884 new Error(704 Effect.withSpan("mcp.plugin.discover_tools", {
885 `No MCP source config for namespace "${entry.namespace}"`,705 attributes: { "mcp.source.namespace": namespace },
886 ),706 }),
707 );
708
709 const existing = yield* ctx.storage.getSource(namespace, scope);
710 const sourceName = manifest.server?.name ?? existing?.name ?? namespace;
711
712 yield* ctx
713 .transaction(
714 Effect.gen(function* () {
715 yield* ctx.storage.removeBindingsByNamespace(namespace, scope);
716 yield* ctx.core.sources.unregister(namespace);
717
718 yield* ctx.storage.putBindings(
719 namespace,
720 scope,
721 manifest.tools.map((e) => ({
722 toolId: `${namespace}.${e.toolId}`,
723 binding: toBinding(e),
724 })),
725 );
726 yield* ctx.core.sources.register({
727 id: namespace,
728 scope,
729 kind: "mcp",
730 name: sourceName,
731 url: sd.transport === "remote" ? sd.endpoint : undefined,
732 canRemove: true,
733 canRefresh: true,
734 canEdit: sd.transport === "remote",
735 tools: manifest.tools.map((e) => ({
736 name: e.toolId,
737 description: e.description ?? `MCP tool: ${e.toolName}`,
738 inputSchema: e.inputSchema,
739 outputSchema: e.outputSchema,
740 })),
741 });
742 }),
743 )
744 .pipe(
745 Effect.withSpan("mcp.plugin.persist_source", {
746 attributes: {
747 "mcp.source.namespace": namespace,
748 "mcp.source.tool_count": manifest.tools.length,
749 },
750 }),
887 );751 );
888 }
889 752
890 return yield* invokeMcpTool({753 return { toolCount: manifest.tools.length };
891 toolId: toolRow.id,
892 toolName: entry.binding.toolName,
893 args,
894 sourceData: sd,
895 invokerScope: ctx.scopes[0]!.id as string,
896 resolveConnector: () =>
897 resolveConnectorInput(sd, ctx, allowStdio).pipe(
898 Effect.flatMap((ci) => createMcpConnector(ci)),
899 Effect.mapError((err) =>
900 err instanceof McpConnectionError
901 ? err
902 : new McpConnectionError({
903 transport: "auto",
904 message:
905 err instanceof Error ? err.message : String(err),
906 }),
907 ),
908 Effect.withSpan("mcp.plugin.resolve_connector", {
909 attributes: {
910 "mcp.source.namespace": entry.namespace,
911 "mcp.source.transport": sd.transport,
912 },
913 }),
914 ),
915 connectionCache: runtime.connectionCache,
916 pendingConnectors: runtime.pendingConnectors,
917 elicit,
918 });
919 }).pipe(754 }).pipe(
920 Effect.withSpan("mcp.plugin.invoke_tool", {755 Effect.withSpan("mcp.plugin.refresh_source", {
921 attributes: {756 attributes: { "mcp.source.namespace": namespace },
922 "mcp.tool.name": toolRow.id,
923 "mcp.tool.source_id": toolRow.source_id,
924 },
925 }),757 }),
926 ),758 );
927 759
928 detect: ({ ctx, url }) =>760 const updateSource = (namespace: string, scope: string, input: McpUpdateSourceInput) =>
929 Effect.gen(function* () {761 Effect.gen(function* () {
930 const trimmed = url.trim();762 const existing = yield* ctx.storage.getSource(namespace, scope);
931 if (!trimmed) return null;763 if (!existing || existing.config.transport !== "remote") return;
764
765 const remote = existing.config;
766 const updatedConfig: McpStoredSourceData = {
767 ...remote,
768 ...(input.endpoint !== undefined ? { endpoint: input.endpoint } : {}),
769 ...(input.headers !== undefined ? { headers: input.headers } : {}),
770 ...(input.auth !== undefined ? { auth: input.auth } : {}),
771 ...(input.queryParams !== undefined ? { queryParams: input.queryParams } : {}),
772 };
773
774 yield* ctx.storage.putSource({
775 namespace,
776 scope,
777 name: input.name?.trim() || existing.name,
778 config: updatedConfig,
779 });
780 }).pipe(
781 Effect.withSpan("mcp.plugin.update_source", {
782 attributes: { "mcp.source.namespace": namespace },
783 }),
784 );
785
786 const getSource = (namespace: string, scope: string) =>
787 ctx.storage.getSource(namespace, scope).pipe(
788 Effect.withSpan("mcp.plugin.get_source", {
789 attributes: { "mcp.source.namespace": namespace },
790 }),
791 );
792
793 return {
794 probeEndpoint,
795 addSource,
796 removeSource,
797 refreshSource,
798 getSource,
799 updateSource,
800 } satisfies McpPluginExtension;
801 },
802
803 invokeTool: ({ ctx, toolRow, args, elicit }) =>
804 Effect.gen(function* () {
805 const runtime = yield* ensureRuntime();
806
807 // toolRow.scope_id is the resolved owning scope of the tool
808 // (innermost-wins from the executor's stack). The matching
809 // mcp_binding + mcp_source rows live at the same scope, so
810 // pin every store lookup to it instead of relying on the
811 // scoped adapter's stack-wide fall-through.
812 const toolScope = toolRow.scope_id as string;
813 const entry = yield* ctx.storage.getBinding(toolRow.id, toolScope).pipe(
814 Effect.withSpan("mcp.plugin.load_binding", {
815 attributes: { "mcp.tool.name": toolRow.id },
816 }),
817 );
818 if (!entry) {
819 return yield* Effect.fail(new Error(`No MCP binding found for tool "${toolRow.id}"`));
820 }
932 821
933 const parsed = yield* Effect.try(() => new URL(trimmed)).pipe(822 const sd = yield* ctx.storage.getSourceConfig(entry.namespace, toolScope).pipe(
934 Effect.option,823 Effect.withSpan("mcp.plugin.load_source_config", {
824 attributes: { "mcp.source.namespace": entry.namespace },
825 }),
826 );
827 if (!sd) {
828 return yield* Effect.fail(
829 new Error(`No MCP source config for namespace "${entry.namespace}"`),
935 );830 );
936 if (parsed._tag === "None") return null;831 }
832
833 return yield* invokeMcpTool({
834 toolId: toolRow.id,
835 toolName: entry.binding.toolName,
836 args,
837 sourceData: sd,
838 invokerScope: ctx.scopes[0]!.id as string,
839 resolveConnector: () =>
840 resolveConnectorInput(sd, ctx, allowStdio).pipe(
841 Effect.flatMap((ci) => createMcpConnector(ci)),
842 Effect.mapError((err) =>
843 err instanceof McpConnectionError
844 ? err
845 : new McpConnectionError({
846 transport: "auto",
847 message: err instanceof Error ? err.message : String(err),
848 }),
849 ),
850 Effect.withSpan("mcp.plugin.resolve_connector", {
851 attributes: {
852 "mcp.source.namespace": entry.namespace,
853 "mcp.source.transport": sd.transport,
854 },
855 }),
856 ),
857 connectionCache: runtime.connectionCache,
858 pendingConnectors: runtime.pendingConnectors,
859 elicit,
860 });
861 }).pipe(
862 Effect.withSpan("mcp.plugin.invoke_tool", {
863 attributes: {
864 "mcp.tool.name": toolRow.id,
865 "mcp.tool.source_id": toolRow.source_id,
866 },
867 }),
868 ),
937 869
938 const name = parsed.value.hostname || "mcp";870 detect: ({ ctx, url }) =>
939 const namespace = deriveMcpNamespace({ endpoint: trimmed });871 Effect.gen(function* () {
872 const trimmed = url.trim();
873 if (!trimmed) return null;
940 874
941 const connector = createMcpConnector({875 const parsed = yield* Effect.try(() => new URL(trimmed)).pipe(Effect.option);
942 transport: "remote",876 if (parsed._tag === "None") return null;
943 endpoint: trimmed,
944 });
945 877
946 const connected = yield* discoverTools(connector).pipe(878 const name = parsed.value.hostname || "mcp";
947 Effect.map(() => true),879 const namespace = deriveMcpNamespace({ endpoint: trimmed });
948 Effect.catchAll(() => Effect.succeed(false)),
949 Effect.withSpan("mcp.plugin.discover_tools"),
950 );
951 880
952 if (connected) {881 const connector = createMcpConnector({
953 return new SourceDetectionResult({882 transport: "remote",
954 kind: "mcp",883 endpoint: trimmed,
955 confidence: "high",884 });
956 endpoint: trimmed,
957 name,
958 namespace,
959 });
960 }
961 885
962 // host publishes RFC 9728 + 8414 metadata) would be classified886 const connected = yield* discoverTools(connector).pipe(
963 // as MCP whenever the cross-plugin detector fans out to us.887 Effect.map(() => true),
964 const shape = yield* probeMcpEndpointShape(trimmed);888 Effect.catchAll(() => Effect.succeed(false)),
965 if (shape.kind !== "mcp") return null;889 Effect.withSpan("mcp.plugin.discover_tools"),
966 890 );
967 // Confirm OAuth metadata is actually reachable. The shape
968 // probe already found a Bearer challenge; the core OAuth
969 // service's probe verifies the AS metadata resolves so we
970 // don't classify endpoints that challenge but have no
971 // discovery.
972 const probeOk = yield* ctx.oauth
973 .probe({ endpoint: trimmed })
974 .pipe(
975 Effect.map(() => true),
976 Effect.catchAll(() => Effect.succeed(false)),
977 Effect.withSpan("mcp.plugin.probe_oauth"),
978 );
979 if (!probeOk) return null;
980 891
892 if (connected) {
981 return new SourceDetectionResult({893 return new SourceDetectionResult({
982 kind: "mcp",894 kind: "mcp",
983 confidence: "high",895 confidence: "high",
@@ -985,49 +897,74 @@ export const mcpPlugin = definePlugin( · 1 unmodified line
985 name,897 name,
986 namespace,898 namespace,
987 });899 });
988 }).pipe(900 }
989 Effect.catchAll(() => Effect.succeed(null)),901
990 Effect.withSpan("mcp.plugin.detect", {902 // host publishes RFC 9728 + 8414 metadata) would be classified
991 attributes: { "mcp.endpoint": url },903 // as MCP whenever the cross-plugin detector fans out to us.
992 }),904 const shape = yield* probeMcpEndpointShape(trimmed);
993 ),905 if (shape.kind !== "mcp") return null;
994 906
995 // MCP tools never require approval at the tool level — elicitation is907 // Confirm OAuth metadata is actually reachable. The shape
996 // handled mid-invocation by the server via the elicit capability.908 // probe already found a Bearer challenge; the core OAuth
997 resolveAnnotations: ({ toolRows }) =>909 // service's probe verifies the AS metadata resolves so we
998 Effect.sync(() => {910 // don't classify endpoints that challenge but have no
999 const out: Record<string, { requiresApproval: boolean }> = {};911 // discovery.
1000 for (const row of toolRows) {912 const probeOk = yield* ctx.oauth.probe({ endpoint: trimmed }).pipe(
1001 out[row.id] = { requiresApproval: false };913 Effect.map(() => true),
1002 }914 Effect.catchAll(() => Effect.succeed(false)),
1003 return out;915 Effect.withSpan("mcp.plugin.probe_oauth"),
1004 }),916 );
1005 917 if (!probeOk) return null;
1006 removeSource: ({ ctx, sourceId, scope }) =>918
1007 Effect.gen(function* () {919 return new SourceDetectionResult({
1008 yield* ctx.storage.removeBindingsByNamespace(sourceId, scope);920 kind: "mcp",
1009 yield* ctx.storage.removeSource(sourceId, scope);921 confidence: "high",
922 endpoint: trimmed,
923 name,
924 namespace,
925 });
926 }).pipe(
927 Effect.catchAll(() => Effect.succeed(null)),
928 Effect.withSpan("mcp.plugin.detect", {
929 attributes: { "mcp.endpoint": url },
1010 }),930 }),
1011 931 ),
1012 refreshSource: () => Effect.void,932
1013 933 // MCP tools never require approval at the tool level — elicitation is
1014 // Connection refresh for oauth2-minted sources is owned by the934 // handled mid-invocation by the server via the elicit capability.
1015 // canonical `"oauth2"` ConnectionProvider that core registers via935 resolveAnnotations: ({ toolRows }) =>
1016 // `makeOAuth2Service`. No MCP-specific provider needed.936 Effect.sync(() => {
1017 937 const out: Record<string, { requiresApproval: boolean }> = {};
1018 close: () =>938 for (const row of toolRows) {
1019 Effect.gen(function* () {939 out[row.id] = { requiresApproval: false };
1020 const runtime = runtimeRef.current;940 }
1021 if (runtime) {941 return out;
1022 runtime.pendingConnectors.clear();942 }),
1023 yield* runtime.connectionCache.invalidateAll;943
1024 yield* Scope.close(runtime.cacheScope, Exit.void);944 removeSource: ({ ctx, sourceId, scope }) =>
1025 runtimeRef.current = null;945 Effect.gen(function* () {
1026 }946 yield* ctx.storage.removeBindingsByNamespace(sourceId, scope);
1027 }).pipe(Effect.withSpan("mcp.plugin.close")),947 yield* ctx.storage.removeSource(sourceId, scope);
1028 };948 }),
1029 },949
1030);950 refreshSource: () => Effect.void,
951
952 // Connection refresh for oauth2-minted sources is owned by the
953 // canonical `"oauth2"` ConnectionProvider that core registers via
954 // `makeOAuth2Service`. No MCP-specific provider needed.
955
956 close: () =>
957 Effect.gen(function* () {
958 const runtime = runtimeRef.current;
959 if (runtime) {
960 runtime.pendingConnectors.clear();
961 yield* runtime.connectionCache.invalidateAll;
962 yield* Scope.close(runtime.cacheScope, Exit.void);
963 runtimeRef.current = null;
964 }
965 }).pipe(Effect.withSpan("mcp.plugin.close")),
966 };
967});
1031 968
1032// ---------------------------------------------------------------------------969// ---------------------------------------------------------------------------
1033// McpPluginExtension — shape of `executor.mcp` for consumers that want970// McpPluginExtension — shape of `executor.mcp` for consumers that want
@@ -1045,10 +982,7 @@ export const mcpPlugin = definePlugin( · 11 unmodified lines
1045 * composition. `UniqueViolationError` passes through — plugins can982 * composition. `UniqueViolationError` passes through — plugins can
1046 * `Effect.catchTag` it if they want a friendlier user-facing error.983 * `Effect.catchTag` it if they want a friendlier user-facing error.
1047 */984 */
1048export type McpExtensionFailure =985export type McpExtensionFailure = McpConnectionError | McpToolDiscoveryError | StorageFailure;
1049 | McpConnectionError
1050 | McpToolDiscoveryError
1051 | StorageFailure;
1052 986
1053export interface McpPluginExtension {987export interface McpPluginExtension {
1054 readonly probeEndpoint: (988 readonly probeEndpoint: (
packages/plugins/mcp/src/sdk/types.ts+3−10
@@ -1,4 +1,7 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { SecretBackedMap, SecretBackedValue } from "@executor/sdk";
3
4export { SecretBackedMap, SecretBackedValue };
2 5
3// ---------------------------------------------------------------------------6// ---------------------------------------------------------------------------
4// Remote transport type7// Remote transport type
@@ -46,18 +49,8 @@ export type McpConnectionAuth = typeof McpConnectionAuth.Type; · 41 unmodified lines
46// Stored source data — discriminated union on transport49// Stored source data — discriminated union on transport
47// ---------------------------------------------------------------------------50// ---------------------------------------------------------------------------
48 51
49export const SecretBackedValue = Schema.Union(
50 Schema.String,
51 Schema.Struct({
52 secretId: Schema.String,
53 prefix: Schema.optional(Schema.String),
54 }),
55);
56export type SecretBackedValue = typeof SecretBackedValue.Type;
57
58/** Common fields for remote string map schemas */52/** Common fields for remote string map schemas */
59const StringMap = Schema.Record({ key: Schema.String, value: Schema.String });53const StringMap = Schema.Record({ key: Schema.String, value: Schema.String });
60const SecretBackedMap = Schema.Record({ key: Schema.String, value: SecretBackedValue });
61 54
62export const McpRemoteSourceData = Schema.Struct({55export const McpRemoteSourceData = Schema.Struct({
63 transport: Schema.Literal("remote"),56 transport: Schema.Literal("remote"),
packages/plugins/openapi/src/api/group.ts+1−9
@@ -1,6 +1,6 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId } from "@executor/sdk";3import { ScopeId, SecretBackedValue } from "@executor/sdk";
4import { InternalError } from "@executor/api";4import { InternalError } from "@executor/api";
5 5
6import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors";6import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors";
@@ -21,14 +21,6 @@ const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId); · 14 unmodified lines
21const namespaceParam = HttpApiSchema.param("namespace", Schema.String);21const namespaceParam = HttpApiSchema.param("namespace", Schema.String);
22const sourceScopeIdParam = HttpApiSchema.param("sourceScopeId", ScopeId);22const sourceScopeIdParam = HttpApiSchema.param("sourceScopeId", ScopeId);
23 23
24const SecretBackedValue = Schema.Union(
25 Schema.String,
26 Schema.Struct({
27 secretId: Schema.String,
28 prefix: Schema.optional(Schema.String),
29 }),
30);
31
32const SpecFetchCredentialsPayload = Schema.Struct({24const SpecFetchCredentialsPayload = Schema.Struct({
33 headers: Schema.optional(Schema.Record({ key: Schema.String, value: SecretBackedValue })),25 headers: Schema.optional(Schema.Record({ key: Schema.String, value: SecretBackedValue })),
34 queryParams: Schema.optional(Schema.Record({ key: Schema.String, value: SecretBackedValue })),26 queryParams: Schema.optional(Schema.Record({ key: Schema.String, value: SecretBackedValue })),
packages/plugins/openapi/src/sdk/plugin.ts+18−25
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
8 SecretId,8 SecretId,
9 SourceDetectionResult,9 SourceDetectionResult,
10 definePlugin,10 definePlugin,
11 resolveSecretBackedMap,
11 type PluginCtx,12 type PluginCtx,
12 type StorageFailure,13 type StorageFailure,
13 type ToolAnnotations,14 type ToolAnnotations,
@@ -471,39 +472,31 @@ const resolveConfiguredHeaders = ( · 457 unmodified lines
471 return resolved;472 return resolved;
472 });473 });
473 474
474const resolveHeaderValue = (475const resolveHeaderValues = (
475 ctx: PluginCtx<OpenapiStore>,476 ctx: PluginCtx<OpenapiStore>,
476 name: string,477 values: Record<string, HeaderValue> | undefined,
477 value: HeaderValue,478): Effect.Effect<Record<string, string>, OpenApiOAuthError | StorageFailure> =>
478): Effect.Effect<string, OpenApiOAuthError | StorageFailure> => {479 resolveSecretBackedMap({
479 if (typeof value === "string") return Effect.succeed(value);480 values,
480 return ctx.secrets.get(value.secretId).pipe(481 getSecret: ctx.secrets.get,
481 Effect.mapError((err) =>482 onMissing: (name) =>
483 new OpenApiOAuthError({
484 message: `Secret not found for "${name}"`,
485 }),
486 onError: (err, name) =>
482 "_tag" in err && err._tag === "SecretOwnedByConnectionError"487 "_tag" in err && err._tag === "SecretOwnedByConnectionError"
483 ? new OpenApiOAuthError({488 ? new OpenApiOAuthError({
484 message: `Secret not found for "${name}"`,489 message: `Secret not found for "${name}"`,
485 })490 })
486 : err,491 : err,
492 }).pipe(
493 Effect.mapError((err) =>
494 "_tag" in err && err._tag === "SecretOwnedByConnectionError"
495 ? new OpenApiOAuthError({ message: "Secret resolution failed" })
496 : err,
487 ),497 ),
488 Effect.flatMap((secret) =>498 Effect.map((resolved) => resolved ?? {}),
489 secret === null
490 ? Effect.fail(
491 new OpenApiOAuthError({
492 message: `Secret not found for "${name}"`,
493 }),
494 )
495 : Effect.succeed(value.prefix ? `${value.prefix}${secret}` : secret),
496 ),
497 );499 );
498};
499
500const resolveHeaderValues = (
501 ctx: PluginCtx<OpenapiStore>,
502 values: Record<string, HeaderValue> | undefined,
503): Effect.Effect<Record<string, string>, OpenApiOAuthError | StorageFailure> =>
504 Effect.forEach(Object.entries(values ?? {}), ([name, value]) =>
505 Effect.map(resolveHeaderValue(ctx, name, value), (resolved) => [name, resolved] as const),
506 ).pipe(Effect.map((entries) => Object.fromEntries(entries)));
507 500
508const resolveOAuthConnectionId = (501const resolveOAuthConnectionId = (
509 ctx: PluginCtx<OpenapiStore>,502 ctx: PluginCtx<OpenapiStore>,
packages/plugins/openapi/src/sdk/types.ts+6−16
@@ -1,5 +1,5 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { ConnectionId, ScopeId, SecretId } from "@executor/sdk";2import { ConnectionId, ScopeId, SecretBackedValue, SecretId } from "@executor/sdk";
3 3
4// ---------------------------------------------------------------------------4// ---------------------------------------------------------------------------
5// Branded IDs5// Branded IDs
@@ -61,10 +61,9 @@ export class EncodingObject extends Schema.Class<EncodingObject>("EncodingObject · 55 unmodified lines
61export class MediaBinding extends Schema.Class<MediaBinding>("MediaBinding")({61export class MediaBinding extends Schema.Class<MediaBinding>("MediaBinding")({
62 contentType: Schema.String,62 contentType: Schema.String,
63 schema: Schema.optionalWith(Schema.Unknown, { as: "Option" }),63 schema: Schema.optionalWith(Schema.Unknown, { as: "Option" }),
64 encoding: Schema.optionalWith(64 encoding: Schema.optionalWith(Schema.Record({ key: Schema.String, value: EncodingObject }), {
65 Schema.Record({ key: Schema.String, value: EncodingObject }),65 as: "Option",
66 { as: "Option" },66 }),
67 ),
68}) {}67}) {}
69 68
70export class OperationRequestBody extends Schema.Class<OperationRequestBody>(69export class OperationRequestBody extends Schema.Class<OperationRequestBody>(
@@ -137,13 +136,7 @@ export class OperationBinding extends Schema.Class<OperationBinding>("OperationB · 66 unmodified lines
137 * A header value — either a static string or a reference to a secret.136 * A header value — either a static string or a reference to a secret.
138 * Stored as JSON-serializable data.137 * Stored as JSON-serializable data.
139 */138 */
140export const HeaderValue = Schema.Union(139export const HeaderValue = SecretBackedValue;
141 Schema.String,
142 Schema.Struct({
143 secretId: Schema.String,
144 prefix: Schema.optional(Schema.String),
145 }),
146);
147export type HeaderValue = typeof HeaderValue.Type;140export type HeaderValue = typeof HeaderValue.Type;
148 141
149export class ConfiguredHeaderBinding extends Schema.Class<ConfiguredHeaderBinding>(142export class ConfiguredHeaderBinding extends Schema.Class<ConfiguredHeaderBinding>(
@@ -154,10 +147,7 @@ export class ConfiguredHeaderBinding extends Schema.Class<ConfiguredHeaderBindin · 4 unmodified lines
154 prefix: Schema.optional(Schema.String),147 prefix: Schema.optional(Schema.String),
155}) {}148}) {}
156 149
157export const ConfiguredHeaderValue = Schema.Union(150export const ConfiguredHeaderValue = Schema.Union(Schema.String, ConfiguredHeaderBinding);
158 Schema.String,
159 ConfiguredHeaderBinding,
160);
161export type ConfiguredHeaderValue = typeof ConfiguredHeaderValue.Type;151export type ConfiguredHeaderValue = typeof ConfiguredHeaderValue.Type;
162 152
163export const OpenApiSourceBindingValue = Schema.Union(153export const OpenApiSourceBindingValue = Schema.Union(
packages/react/src/plugins/http-credentials.tsx+2−7
@@ -1,6 +1,6 @@
1import { useId } from "react";1import { useId } from "react";
2import { PlusIcon } from "lucide-react";2import { PlusIcon } from "lucide-react";
3import type { ScopeId } from "@executor/sdk";3import type { ScopeId, SecretBackedValue } from "@executor/sdk";
4 4
5import { Button } from "../components/button";5import { Button } from "../components/button";
6import { CardStack, CardStackContent, CardStackEntry } from "../components/card-stack";6import { CardStack, CardStackContent, CardStackEntry } from "../components/card-stack";
@@ -15,12 +15,7 @@ import { · 8 unmodified lines
15} from "./secret-header-auth";15} from "./secret-header-auth";
16import type { SecretPickerSecret } from "./secret-picker";16import type { SecretPickerSecret } from "./secret-picker";
17 17
18export type SecretBackedValue =18export type { SecretBackedValue };
19 | string
20 | {
21 readonly secretId: string;
22 readonly prefix?: string;
23 };
24 19
25export type QueryParamState = {20export type QueryParamState = {
26 name: string;21 name: string;
packages/react/src/plugins/oauth-sign-in.tsx+5−8
@@ -5,14 +5,11 @@ import { cancelOAuth, startOAuth } from "../api/atoms"; · 4 unmodified lines
5import { openOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup";5import { openOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup";
6import { useScope } from "../api/scope-context";6import { useScope } from "../api/scope-context";
7import { Button } from "../components/button";7import { Button } from "../components/button";
8import { OAUTH_POPUP_MESSAGE_TYPE, type OAuthStrategy } from "@executor/sdk";8import {
9 9 OAUTH_POPUP_MESSAGE_TYPE,
10type SecretBackedValue =10 type OAuthStrategy,
11 | string11 type SecretBackedValue,
12 | {12} from "@executor/sdk";
13 readonly secretId: string;
14 readonly prefix?: string;
15 };
16 13
17export type OAuthCompletionPayload = {14export type OAuthCompletionPayload = {
18 readonly connectionId: string;15 readonly connectionId: string;