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
1414 OAuthProbeError,
1515 OAuthSessionNotFoundError,
1616 OAuthStartError,
17+ resolveSecretBackedMap,
1718 type Executor,
1819 type OAuthStrategy,
20+ type SecretBackedValue,
1921 } from "@executor/sdk";
2022
2123 import { ExecutorApi } from "../api";
@@ -24,32 +26,23 @@ import { ExecutorService } from "../services"; · 2 unmodified lines
2426
2527 const OAUTH_POPUP_CHANNEL = OAUTH_POPUP_MESSAGE_TYPE;
2628
27−type SecretBackedValue =
28− | string
29− | { readonly secretId: string; readonly prefix?: string };
30−
31−const resolveSecretBackedMap = (
29+const resolveOAuthSecretBackedMap = (
3230 executor: Executor,
3331 values: Record<string, SecretBackedValue> | undefined,
3432 makeError: (message: string) => OAuthProbeError | OAuthStartError,
3533 ) =>
36− Effect.gen(function* () {
37− const resolved: Record<string, string> = {};
38− for (const [name, value] of Object.entries(values ?? {})) {
39− if (typeof value === "string") {
40− resolved[name] = value;
41− continue;
42− }
43− const secret = yield* executor.secrets.get(value.secretId).pipe(
44− Effect.mapError(() => makeError(`Secret not found for "${name}"`)),
45− );
46− if (secret === null) {
47− return yield* Effect.fail(makeError(`Secret not found for "${name}"`));
48− }
49− resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret;
50− }
51− return Object.keys(resolved).length > 0 ? resolved : undefined;
52− });
34+ resolveSecretBackedMap({
35+ values,
36+ getSecret: executor.secrets.get,
37+ onMissing: (name) => makeError(`Secret not found for "${name}"`),
38+ onError: (_error, name) => makeError(`Secret not found for "${name}"`),
39+ }).pipe(
40+ Effect.mapError((error) =>
41+ error instanceof OAuthProbeError || error instanceof OAuthStartError
42+ ? error
43+ : makeError("Secret resolution failed"),
44+ ),
45+ );
5346
5447 const toPopupErrorMessage = (error: unknown): string => {
5548 if (error instanceof OAuthStartError) return error.message;
@@ -67,12 +60,12 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 11 unmodified lines
6760 capture(
6861 Effect.gen(function* () {
6962 const executor = yield* ExecutorService;
70− const headers = yield* resolveSecretBackedMap(
63+ const headers = yield* resolveOAuthSecretBackedMap(
7164 executor,
7265 payload.headers,
7366 (message) => new OAuthProbeError({ message }),
7467 );
75− const queryParams = yield* resolveSecretBackedMap(
68+ const queryParams = yield* resolveOAuthSecretBackedMap(
7669 executor,
7770 payload.queryParams,
7871 (message) => new OAuthProbeError({ message }),
@@ -89,14 +82,13 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 10 unmodified lines
8982 capture(
9083 Effect.gen(function* () {
9184 const executor = yield* ExecutorService;
92− const tokenScope =
93− payload.tokenScope ?? (executor.scopes[0]!.id as unknown as string);
94− const headers = yield* resolveSecretBackedMap(
85+ const tokenScope = payload.tokenScope ?? (executor.scopes[0]!.id as unknown as string);
86+ const headers = yield* resolveOAuthSecretBackedMap(
9587 executor,
9688 payload.headers,
9789 (message) => new OAuthStartError({ message }),
9890 );
99− const queryParams = yield* resolveSecretBackedMap(
91+ const queryParams = yield* resolveOAuthSecretBackedMap(
10092 executor,
10193 payload.queryParams,
10294 (message) => new OAuthStartError({ message }),
@@ -144,18 +136,18 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 41 unmodified lines
144136 const executor = yield* ExecutorService;
145137 const html = yield* runOAuthCallback({
146138 complete: ({ state, code, error }) =>
147− executor.oauth.complete({
148− state,
149− code: code ?? undefined,
150− error: error ?? undefined,
151− }).pipe(
152− Effect.tapErrorCause((cause) =>
153− Effect.logError("OAuth callback completion failed", cause),
154− ),
155− Effect.catchAllCause(() =>
156− Effect.fail(new Error("Authentication failed")),
139+ executor.oauth
140+ .complete({
141+ state,
142+ code: code ?? undefined,
143+ error: error ?? undefined,
144+ })
145+ .pipe(
146+ Effect.tapErrorCause((cause) =>
147+ Effect.logError("OAuth callback completion failed", cause),
148+ ),
149+ Effect.catchAllCause(() => Effect.fail(new Error("Authentication failed"))),
157150 ),
158− ),
159151 urlParams,
160152 toErrorMessage: toPopupErrorMessage,
161153 channelName: OAUTH_POPUP_CHANNEL,
packages/core/api/src/oauth/api.ts+3−15
@@ -17,20 +17,12 @@ import { · 16 unmodified lines
1717 OAuthStartError,
1818 OAuthStrategySchema,
1919 ScopeId,
20+ SecretBackedMap,
2021 } from "@executor/sdk";
2122
2223 import { InternalError } from "../observability";
2324
2425 const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId);
25−const SecretBackedValue = Schema.Union(
26− Schema.String,
27− Schema.Struct({
28− secretId: Schema.String,
29− prefix: Schema.optional(Schema.String),
30− }),
31−);
32−const SecretBackedMap = Schema.Record({ key: Schema.String, value: SecretBackedValue });
33−
3426 // ---------------------------------------------------------------------------
3527 // Probe — decide between dynamic-DCR and paste-your-credentials flows
3628 // ---------------------------------------------------------------------------
@@ -42,9 +34,7 @@ const ProbePayload = Schema.Struct({ · 5 unmodified lines
4234 });
4335
4436 const ProbeResponse = Schema.Struct({
45− resourceMetadata: Schema.NullOr(
46− Schema.Record({ key: Schema.String, value: Schema.Unknown }),
47− ),
37+ resourceMetadata: Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.Unknown })),
4838 resourceMetadataUrl: Schema.NullOr(Schema.String),
4939 authorizationServerMetadata: Schema.NullOr(
5040 Schema.Record({ key: Schema.String, value: Schema.Unknown }),
@@ -91,9 +81,7 @@ const StartResponse = Schema.Struct({ · 40 unmodified lines
9181 * `client-credentials` (no redirect). */
9282 authorizationUrl: Schema.NullOr(Schema.String),
9383 /** Filled for strategies that mint the Connection inline. */
94− completedConnection: Schema.NullOr(
95− Schema.Struct({ connectionId: Schema.String }),
96− ),
84+ completedConnection: Schema.NullOr(Schema.Struct({ connectionId: Schema.String })),
9785 });
9886
9987 // ---------------------------------------------------------------------------
packages/core/sdk/src/index.ts+10−15
@@ -93,11 +93,15 @@ export { · 92 unmodified lines
9393 } from "./policies";
9494
9595 // Secrets
96+export { SecretRef, SetSecretInput, type SecretProvider } from "./secrets";
97+
9698 export {
97− SecretRef,
98− SetSecretInput,
99− type SecretProvider,
100−} from "./secrets";
99+ SecretBackedMap,
100+ SecretBackedValue,
101+ isSecretBackedRef,
102+ resolveSecretBackedMap,
103+ type ResolveSecretBackedMapOptions,
104+} from "./secret-backed-value";
101105
102106 // Connections
103107 export {
@@ -236,22 +240,13 @@ export { · 132 unmodified lines
236240 } from "./executor";
237241
238242 // CLI config
239−export {
240− defineExecutorConfig,
241− type ExecutorCliConfig,
242− type ExecutorDialect,
243−} from "./config";
243+export { defineExecutorConfig, type ExecutorCliConfig, type ExecutorDialect } from "./config";
244244
245245 // Test helper
246246 export { makeTestConfig } from "./testing";
247247
248248 // JSON schema $ref helpers (used by openapi for $defs handling)
249−export {
250− hoistDefinitions,
251− collectRefs,
252− reattachDefs,
253− normalizeRefs,
254−} from "./schema-refs";
249+export { hoistDefinitions, collectRefs, reattachDefs, normalizeRefs } from "./schema-refs";
255250
256251 // TypeScript preview generation from JSON schemas
257252 export {
packages/core/sdk/src/secret-backed-value.ts+72−0
@@ -0,0 +1,72 @@
1+import { Effect, Schema } from "effect";
2+
3+export const SecretBackedValue = Schema.Union(
4+ Schema.String,
5+ Schema.Struct({
6+ secretId: Schema.String,
7+ prefix: Schema.optional(Schema.String),
8+ }),
9+);
10+export type SecretBackedValue = typeof SecretBackedValue.Type;
11+
12+export const SecretBackedMap = Schema.Record({
13+ key: Schema.String,
14+ value: SecretBackedValue,
15+});
16+export type SecretBackedMap = typeof SecretBackedMap.Type;
17+
18+export const isSecretBackedRef = (
19+ value: SecretBackedValue,
20+): value is Extract<SecretBackedValue, { readonly secretId: string }> => typeof value !== "string";
21+
22+export 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+
37+export 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 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import { Schema } from "effect";
3−import { ScopeId } from "@executor/sdk";
3+import { ScopeId, SecretBackedValue } from "@executor/sdk";
44 import { InternalError } from "@executor/api";
55
6−import {
7− GoogleDiscoveryParseError,
8− GoogleDiscoverySourceError,
9−} from "../sdk/errors";
6+import { GoogleDiscoveryParseError, GoogleDiscoverySourceError } from "../sdk/errors";
107 import { GoogleDiscoveryStoredSourceSchema } from "../sdk/stored-source";
118
129 export { HttpApiSchema };
@@ -14,14 +11,6 @@ export { HttpApiSchema }; · 1 unmodified line
1411 const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId);
1512 const namespaceParam = HttpApiSchema.param("namespace", Schema.String);
1613
17−const SecretBackedValue = Schema.Union(
18− Schema.String,
19− Schema.Struct({
20− secretId: Schema.String,
21− prefix: Schema.optional(Schema.String),
22− }),
23−);
24−
2514 const DiscoveryCredentialsPayload = Schema.Struct({
2615 headers: Schema.optional(Schema.Record({ key: Schema.String, value: SecretBackedValue })),
2716 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
33 import {
44 SourceDetectionResult,
55 definePlugin,
6+ resolveSecretBackedMap,
67 type PluginCtx,
78 type StorageFailure,
89 type ToolAnnotations,
@@ -16,10 +17,7 @@ import { · 7 unmodified lines
1617 } from "./binding-store";
1718 import { extractGoogleDiscoveryManifest } from "./document";
1819 import { annotationsForOperation, invokeGoogleDiscoveryTool } from "./invoke";
19−import {
20− GoogleDiscoveryParseError,
21− GoogleDiscoverySourceError,
22−} from "./errors";
20+import { GoogleDiscoveryParseError, GoogleDiscoverySourceError } from "./errors";
2321 import type {
2422 GoogleDiscoveryAuth,
2523 GoogleDiscoveryFetchCredentials,
@@ -93,10 +91,7 @@ export interface GoogleDiscoveryPluginExtension { · 67 unmodified lines
9391 { readonly toolCount: number; readonly namespace: string },
9492 GoogleDiscoveryParseError | GoogleDiscoverySourceError | StorageFailure
9593 >;
96− readonly removeSource: (
97− namespace: string,
98− scope: string,
99− ) => Effect.Effect<void, StorageFailure>;
94+ readonly removeSource: (namespace: string, scope: string) => Effect.Effect<void, StorageFailure>;
10095 readonly getSource: (
10196 namespace: string,
10297 scope: string,
@@ -143,57 +138,50 @@ const resolveGoogleDiscoveryCredentials = ( · 40 unmodified lines
143138 credentials: GoogleDiscoveryFetchCredentials | undefined,
144139 ctx: PluginCtx<GoogleDiscoveryStore>,
145140 ): Effect.Effect<
146− | { headers?: Record<string, string>; queryParams?: Record<string, string> }
147− | undefined,
141+ { headers?: Record<string, string>; queryParams?: Record<string, string> } | undefined,
148142 GoogleDiscoverySourceError
149143 > =>
150144 Effect.gen(function* () {
151145 if (!credentials) return undefined;
152− const headers: Record<string, string> = {};
153− for (const [name, value] of Object.entries(credentials.headers ?? {})) {
154− if (typeof value === "string") {
155− headers[name] = value;
156− continue;
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({
146+ const headers = yield* resolveSecretBackedMap({
147+ values: credentials.headers,
148+ getSecret: ctx.secrets.get,
149+ onMissing: (name) =>
150+ new GoogleDiscoverySourceError({
168151 message: `Secret not found for header "${name}"`,
169− });
170− }
171− headers[name] = value.prefix ? `${value.prefix}${secret}` : secret;
172− }
173− const queryParams: Record<string, string> = {};
174− for (const [name, value] of Object.entries(credentials.queryParams ?? {})) {
175− if (typeof value === "string") {
176− queryParams[name] = value;
177− continue;
178− }
179− const secret = yield* ctx.secrets.get(value.secretId).pipe(
180− Effect.mapError(
181− () =>
182− new GoogleDiscoverySourceError({
183− message: `Secret not found for query parameter "${name}"`,
184− }),
185− ),
186− );
187− if (secret === null) {
188− return yield* new GoogleDiscoverySourceError({
152+ }),
153+ onError: (_error, name) =>
154+ new GoogleDiscoverySourceError({
155+ message: `Secret not found for header "${name}"`,
156+ }),
157+ }).pipe(
158+ Effect.mapError((err) =>
159+ err instanceof GoogleDiscoverySourceError
160+ ? err
161+ : new GoogleDiscoverySourceError({ message: "Secret resolution failed" }),
162+ ),
163+ );
164+ const queryParams = yield* resolveSecretBackedMap({
165+ values: credentials.queryParams,
166+ getSecret: ctx.secrets.get,
167+ onMissing: (name) =>
168+ new GoogleDiscoverySourceError({
189169 message: `Secret not found for query parameter "${name}"`,
190− });
191− }
192− queryParams[name] = value.prefix ? `${value.prefix}${secret}` : secret;
193− }
170+ }),
171+ onError: (_error, name) =>
172+ new GoogleDiscoverySourceError({
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+ );
194182 return {
195− ...(Object.keys(headers).length > 0 ? { headers } : {}),
196− ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}),
183+ ...(headers ? { headers } : {}),
184+ ...(queryParams ? { queryParams } : {}),
197185 };
198186 });
199187
@@ -207,9 +195,7 @@ const fetchDiscoveryDocument = ( · 7 unmodified lines
207195 Effect.tryPromise({
208196 try: async () => {
209197 const url = new URL(normalizeDiscoveryUrl(discoveryUrl));
210− for (const [key, value] of Object.entries(
211− credentials?.queryParams ?? {},
212− )) {
198+ for (const [key, value] of Object.entries(credentials?.queryParams ?? {})) {
213199 url.searchParams.set(key, value);
214200 }
215201 const response = await fetch(url.toString(), {
@@ -237,14 +223,9 @@ const normalizeSlug = (value: string): string => · 21 unmodified lines
237223 .replace(/[^a-z0-9]+/g, "_")
238224 .replace(/^_+|_+$/g, "");
239225
240−const deriveNamespace = (input: {
241− name: string;
242− service: string;
243− version: string;
244−}): string =>
226+const deriveNamespace = (input: { name: string; service: string; version: string }): string =>
245227 normalizeSlug(
246− input.name ||
247− `google_${input.service}_${input.version.replace(/[^a-zA-Z0-9]+/g, "_")}`,
228+ input.name || `google_${input.service}_${input.version.replace(/[^a-zA-Z0-9]+/g, "_")}`,
248229 ) || `google_${input.service}`;
249230
250231 // Connection refresh state is owned by the canonical `"oauth2"`
@@ -281,8 +262,7 @@ const registerManifest = ( · 30 unmodified lines
281262 name: method.toolPath,
282263 description: Option.getOrElse(
283264 method.description,
284− () =>
285− `${method.binding.method.toUpperCase()} ${method.binding.pathTemplate}`,
265+ () => `${method.binding.method.toUpperCase()} ${method.binding.pathTemplate}`,
286266 ),
287267 inputSchema: Option.getOrUndefined(method.inputSchema),
288268 outputSchema: Option.getOrUndefined(method.outputSchema),
@@ -300,12 +280,7 @@ const registerManifest = ( · 11 unmodified lines
300280 yield* Effect.forEach(
301281 manifest.methods,
302282 (method) =>
303− ctx.storage.putBinding(
304− `${namespace}.${method.toolPath}`,
305− namespace,
306− scope,
307− method.binding,
308− ),
283+ ctx.storage.putBinding(`${namespace}.${method.toolPath}`, namespace, scope, method.binding),
309284 { discard: true },
310285 );
311286
@@ -332,30 +307,21 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 20 unmodified lines
332307 ({
333308 probeDiscovery: (input) =>
334309 Effect.gen(function* () {
335− const discoveryUrl =
336− typeof input === "string" ? input : input.discoveryUrl;
310+ const discoveryUrl = typeof input === "string" ? input : input.discoveryUrl;
337311 const credentials =
338312 typeof input === "string"
339313 ? undefined
340− : yield* resolveGoogleDiscoveryCredentials(
341− input.credentials,
342− ctx,
343− );
314+ : yield* resolveGoogleDiscoveryCredentials(input.credentials, ctx);
344315 const text = yield* fetchDiscoveryDocument(discoveryUrl, credentials);
345316 const manifest = yield* extractGoogleDiscoveryManifest(text);
346317 const scopes = Object.keys(
347− manifest.oauthScopes._tag === "Some"
348− ? manifest.oauthScopes.value
349− : {},
318+ manifest.oauthScopes._tag === "Some" ? manifest.oauthScopes.value : {},
350319 ).sort();
351320 const operations = manifest.methods.map((method) => ({
352321 toolPath: method.toolPath,
353322 method: method.binding.method,
354323 pathTemplate: method.binding.pathTemplate,
355− description:
356− method.description._tag === "Some"
357− ? method.description.value
358− : null,
324+ description: method.description._tag === "Some" ? method.description.value : null,
359325 }));
360326 return {
361327 name:
@@ -374,14 +340,8 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 12 unmodified lines
374340 addSource: (input) =>
375341 ctx.transaction(
376342 Effect.gen(function* () {
377− const credentials = yield* resolveGoogleDiscoveryCredentials(
378− input.credentials,
379− ctx,
380− );
381− const text = yield* fetchDiscoveryDocument(
382− input.discoveryUrl,
383− credentials,
384− );
343+ const credentials = yield* resolveGoogleDiscoveryCredentials(input.credentials, ctx);
344+ const text = yield* fetchDiscoveryDocument(input.discoveryUrl, credentials);
385345 const manifest = yield* extractGoogleDiscoveryManifest(text);
386346 const namespace =
387347 input.namespace ??
@@ -447,25 +407,16 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 59 unmodified lines
447407 const typedCtx = ctx as PluginCtx<GoogleDiscoveryStore>;
448408 const scopes = new Set<string>();
449409 for (const row of toolRows) scopes.add(row.scope_id as string);
450− const byScope = new Map<
451− string,
452− ReadonlyMap<string, GoogleDiscoveryMethodBinding>
453− >();
410+ const byScope = new Map<string, ReadonlyMap<string, GoogleDiscoveryMethodBinding>>();
454411 for (const scope of scopes) {
455− const bindings = yield* typedCtx.storage.getBindingsForSource(
456− sourceId,
457− scope,
458− );
412+ const bindings = yield* typedCtx.storage.getBindingsForSource(sourceId, scope);
459413 byScope.set(scope, bindings);
460414 }
461415 const out: Record<string, ToolAnnotations> = {};
462416 for (const row of toolRows) {
463417 const binding = byScope.get(row.scope_id as string)?.get(row.id);
464418 if (binding) {
465− out[row.id] = annotationsForOperation(
466− binding.method,
467− binding.pathTemplate,
468− );
419+ out[row.id] = annotationsForOperation(binding.method, binding.pathTemplate);
469420 }
470421 }
471422 return out;
@@ -482,14 +433,11 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 10 unmodified lines
482433 Effect.gen(function* () {
483434 const trimmed = url.trim();
484435 if (!trimmed) return null;
485− const parsed = yield* Effect.try(() => new URL(trimmed)).pipe(
486− Effect.option,
487− );
436+ const parsed = yield* Effect.try(() => new URL(trimmed)).pipe(Effect.option);
488437 if (parsed._tag === "None") return null;
489438
490439 const isGoogleUrl = trimmed.includes("googleapis.com");
491− const isDiscoveryPath =
492− trimmed.includes("/discovery/") || trimmed.includes("$discovery");
440+ const isDiscoveryPath = trimmed.includes("/discovery/") || trimmed.includes("$discovery");
493441 if (!isGoogleUrl && !isDiscoveryPath) return null;
494442
495443 const discoveryText = yield* fetchDiscoveryDocument(trimmed).pipe(
@@ -497,9 +445,9 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 1 unmodified line
497445 );
498446 if (!discoveryText) return null;
499447
500− const manifest = yield* extractGoogleDiscoveryManifest(
501− discoveryText,
502− ).pipe(Effect.catchAll(() => Effect.succeed(null)));
448+ const manifest = yield* extractGoogleDiscoveryManifest(discoveryText).pipe(
449+ Effect.catchAll(() => Effect.succeed(null)),
450+ );
503451 if (!manifest) return null;
504452
505453 const name = Option.getOrElse(
@@ -529,10 +477,7 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 23 unmodified lines
529477 existing.config.credentials,
530478 typedCtx,
531479 );
532− const text = yield* fetchDiscoveryDocument(
533− existing.config.discoveryUrl,
534− credentials,
535− );
480+ const text = yield* fetchDiscoveryDocument(existing.config.discoveryUrl, credentials);
536481 const manifest = yield* extractGoogleDiscoveryManifest(text);
537482 const next = new GoogleDiscoveryStoredSourceDataSchema({
538483 ...existing.config,
@@ -542,11 +487,7 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 3 unmodified lines
542487 servicePath: manifest.servicePath,
543488 });
544489 yield* registerManifest(typedCtx, sourceId, scope, manifest, next);
545− }).pipe(
546− Effect.mapError((err) =>
547− err instanceof Error ? err : new Error(String(err)),
548− ),
549− ),
490+ }).pipe(Effect.mapError((err) => (err instanceof Error ? err : new Error(String(err))))),
550491
551492 // Connection refresh is owned by the canonical `"oauth2"`
552493 // ConnectionProvider registered by core — no plugin-specific handler
packages/plugins/google-discovery/src/sdk/types.ts+4−11
@@ -1,4 +1,5 @@
11 import { Schema } from "effect";
2+import { SecretBackedValue } from "@executor/sdk";
23
34 export const GoogleDiscoveryHttpMethod = Schema.Literal(
45 "get",
@@ -90,15 +91,8 @@ export const GoogleDiscoveryAuth = Schema.Union( · 85 unmodified lines
9091 );
9192 export type GoogleDiscoveryAuth = typeof GoogleDiscoveryAuth.Type;
9293
93−export const GoogleDiscoveryCredentialValue = Schema.Union(
94− Schema.String,
95− Schema.Struct({
96− secretId: Schema.String,
97− prefix: Schema.optional(Schema.String),
98− }),
99−);
100−export type GoogleDiscoveryCredentialValue =
101− typeof GoogleDiscoveryCredentialValue.Type;
94+export const GoogleDiscoveryCredentialValue = SecretBackedValue;
95+export type GoogleDiscoveryCredentialValue = typeof GoogleDiscoveryCredentialValue.Type;
10296
10397 export const GoogleDiscoveryFetchCredentials = Schema.Struct({
10498 headers: Schema.optional(
@@ -108,8 +102,7 @@ export const GoogleDiscoveryFetchCredentials = Schema.Struct({ · 3 unmodified lines
108102 Schema.Record({ key: Schema.String, value: GoogleDiscoveryCredentialValue }),
109103 ),
110104 });
111−export type GoogleDiscoveryFetchCredentials =
112− typeof GoogleDiscoveryFetchCredentials.Type;
105+export type GoogleDiscoveryFetchCredentials = typeof GoogleDiscoveryFetchCredentials.Type;
113106
114107 export class GoogleDiscoveryStoredSourceData extends Schema.Class<GoogleDiscoveryStoredSourceData>(
115108 "GoogleDiscoveryStoredSourceData",
packages/plugins/graphql/src/sdk/invoke.ts+8−26
@@ -1,5 +1,6 @@
11 import { Effect, Layer, Option } from "effect";
22 import { HttpClient, HttpClientRequest } from "@effect/platform";
3+import { resolveSecretBackedMap } from "@executor/sdk";
34
45 import { GraphqlInvocationError } from "./errors";
56 import { type HeaderValue, type OperationBinding, InvocationResult } from "./types";
@@ -17,33 +18,14 @@ export const resolveHeaders = ( · 11 unmodified lines
1718 (acc, [, value]) => (typeof value === "string" ? acc : acc + 1),
1819 0,
1920 );
20− return Effect.gen(function* () {
21− // Resolve secret-backed headers in parallel. Missing / failing
22− // lookups drop the header rather than fail the invocation, same
23− // as the serial version.
24− const values = yield* Effect.all(
25− entries.map(([name, value]) =>
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;
21+ return resolveSecretBackedMap({
22+ values: headers,
23+ getSecret: (secretId) =>
24+ secrets.get(secretId).pipe(Effect.catchAll(() => Effect.succeed(null))),
25+ missing: "drop",
26+ onMissing: () => undefined as never,
4627 }).pipe(
28+ Effect.map((resolved) => resolved ?? {}),
4729 Effect.withSpan("plugin.graphql.secret.resolve", {
4830 attributes: {
4931 "plugin.graphql.headers.total": entries.length,
packages/plugins/graphql/src/sdk/types.ts+14−37
@@ -1,4 +1,5 @@
11 import { Schema } from "effect";
2+import { SecretBackedValue } from "@executor/sdk";
23
34 // ---------------------------------------------------------------------------
45 // GraphQL operation kind
@@ -11,18 +12,14 @@ export type GraphqlOperationKind = typeof GraphqlOperationKind.Type; · 6 unmodified lines
1112 // Extracted field (becomes a tool)
1213 // ---------------------------------------------------------------------------
1314
14−export class GraphqlArgument extends Schema.Class<GraphqlArgument>(
15− "GraphqlArgument",
16−)({
15+export class GraphqlArgument extends Schema.Class<GraphqlArgument>("GraphqlArgument")({
1716 name: Schema.String,
1817 typeName: Schema.String,
1918 required: Schema.Boolean,
2019 description: Schema.optionalWith(Schema.String, { as: "Option" }),
2120 }) {}
2221
23−export class ExtractedField extends Schema.Class<ExtractedField>(
24− "ExtractedField",
25−)({
22+export class ExtractedField extends Schema.Class<ExtractedField>("ExtractedField")({
2623 /** e.g. "user", "createUser" */
2724 fieldName: Schema.String,
2825 /** "query" or "mutation" */
@@ -35,9 +32,7 @@ export class ExtractedField extends Schema.Class<ExtractedField>( · 6 unmodified lines
3532 returnTypeName: Schema.String,
3633 }) {}
3734
38−export class ExtractionResult extends Schema.Class<ExtractionResult>(
39− "ExtractionResult",
40−)({
35+export class ExtractionResult extends Schema.Class<ExtractionResult>("ExtractionResult")({
4136 /** Schema name from introspection */
4237 schemaName: Schema.optionalWith(Schema.String, { as: "Option" }),
4338 fields: Schema.Array(ExtractedField),
@@ -47,9 +42,7 @@ export class ExtractionResult extends Schema.Class<ExtractionResult>( · 3 unmodified lines
4742 // Operation binding — minimal data needed to invoke
4843 // ---------------------------------------------------------------------------
4944
50−export class OperationBinding extends Schema.Class<OperationBinding>(
51− "OperationBinding",
52−)({
45+export class OperationBinding extends Schema.Class<OperationBinding>("OperationBinding")({
5346 kind: GraphqlOperationKind,
5447 fieldName: Schema.String,
5548 /** The full GraphQL query/mutation string */
@@ -62,13 +55,7 @@ export class OperationBinding extends Schema.Class<OperationBinding>( · 6 unmodified lines
6255 // Invocation
6356 // ---------------------------------------------------------------------------
6457
65−export const HeaderValue = Schema.Union(
66− Schema.String,
67− Schema.Struct({
68− secretId: Schema.String,
69− prefix: Schema.optional(Schema.String),
70− }),
71−);
58+export const HeaderValue = SecretBackedValue;
7259 export type HeaderValue = typeof HeaderValue.Type;
7360 export const QueryParamValue = HeaderValue;
7461 export type QueryParamValue = typeof QueryParamValue.Type;
@@ -86,30 +73,20 @@ export const GraphqlSourceAuth = Schema.Union( · 11 unmodified lines
8673 );
8774 export type GraphqlSourceAuth = typeof GraphqlSourceAuth.Type;
8875
89−export class InvocationConfig extends Schema.Class<InvocationConfig>(
90− "InvocationConfig",
91−)({
76+export class InvocationConfig extends Schema.Class<InvocationConfig>("InvocationConfig")({
9277 /** The GraphQL endpoint URL */
9378 endpoint: Schema.String,
9479 /** Headers applied to every request. Values can reference secrets. */
95− headers: Schema.optionalWith(
96− Schema.Record({ key: Schema.String, value: HeaderValue }),
97− {
98− default: () => ({}),
99− },
100− ),
80+ headers: Schema.optionalWith(Schema.Record({ key: Schema.String, value: HeaderValue }), {
81+ default: () => ({}),
82+ }),
10183 /** Query parameters applied to every request. Values can reference secrets. */
102− queryParams: Schema.optionalWith(
103− Schema.Record({ key: Schema.String, value: QueryParamValue }),
104− {
105− default: () => ({}),
106− },
107− ),
84+ queryParams: Schema.optionalWith(Schema.Record({ key: Schema.String, value: QueryParamValue }), {
85+ default: () => ({}),
86+ }),
10887 }) {}
10988
110−export class InvocationResult extends Schema.Class<InvocationResult>(
111− "InvocationResult",
112−)({
89+export class InvocationResult extends Schema.Class<InvocationResult>("InvocationResult")({
11390 status: Schema.Number,
11491 data: Schema.NullOr(Schema.Unknown),
11592 errors: Schema.NullOr(Schema.Unknown),
packages/plugins/mcp/src/api/group.ts+7−16
@@ -1,12 +1,9 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import { Schema } from "effect";
3−import { ScopeId } from "@executor/sdk";
3+import { ScopeId, SecretBackedMap } from "@executor/sdk";
44 import { InternalError } from "@executor/api";
55
6−import {
7− McpConnectionError,
8− McpToolDiscoveryError,
9−} from "../sdk/errors";
6+import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors";
107 import { McpStoredSourceSchema } from "../sdk/stored-source";
118
129 // Re-export for handler use
@@ -43,15 +40,6 @@ const AuthPayload = Schema.Union( · 30 unmodified lines
4340 );
4441
4542 const StringMap = Schema.Record({ key: Schema.String, value: Schema.String });
46−const SecretBackedValue = Schema.Union(
47− Schema.String,
48− Schema.Struct({
49− secretId: Schema.String,
50− prefix: Schema.optional(Schema.String),
51− }),
52−);
53−const SecretBackedMap = Schema.Record({ key: Schema.String, value: SecretBackedValue });
54−
5543 // ---------------------------------------------------------------------------
5644 // Add source — discriminated union on transport
5745 // ---------------------------------------------------------------------------
@@ -169,8 +157,11 @@ export class McpGroup extends HttpApiGroup.make("mcp") · 111 unmodified lines
169157 .addSuccess(RefreshSourceResponse),
170158 )
171159 .add(
172− HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}`
173− .addSuccess(Schema.NullOr(McpStoredSourceSchema)),
160+ HttpApiEndpoint.get(
161+ "getSource",
162+ )`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}`.addSuccess(
163+ Schema.NullOr(McpStoredSourceSchema),
164+ ),
174165 )
175166 .add(
176167 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
55 import {
66 SourceDetectionResult,
77 definePlugin,
8+ resolveSecretBackedMap as resolveSharedSecretBackedMap,
89 type PluginCtx,
910 type StorageFailure,
1011 } from "@executor/sdk";
@@ -15,21 +16,11 @@ import { · 4 unmodified lines
1516 type McpBindingStore,
1617 type McpStoredSource,
1718 } from "./binding-store";
18−import {
19− createMcpConnector,
20− type ConnectorInput,
21− type McpConnection,
22−} from "./connection";
19+import { createMcpConnector, type ConnectorInput, type McpConnection } from "./connection";
2320 import { discoverTools } from "./discover";
24−import {
25− McpConnectionError,
26− McpToolDiscoveryError,
27−} from "./errors";
21+import { McpConnectionError, McpToolDiscoveryError } from "./errors";
2822 import { invokeMcpTool } from "./invoke";
29−import {
30− deriveMcpNamespace,
31− type McpToolManifestEntry,
32−} from "./manifest";
23+import { deriveMcpNamespace, type McpToolManifestEntry } from "./manifest";
3324 import { probeMcpEndpointShape } from "./probe-shape";
3425 import {
3526 McpToolBinding,
@@ -200,38 +191,26 @@ const remoteConnectionError = (message: string) => · 164 unmodified lines
200191 const mcpDiscoveryError = (message: string) =>
201192 new McpToolDiscoveryError({ stage: "list_tools", message });
202193
203−const resolveSecretBackedValue = (
204− value: SecretBackedValue,
194+const resolveSecretBackedMap = (
195+ values: Record<string, SecretBackedValue> | undefined,
205196 ctx: PluginCtx<McpBindingStore>,
206−): Effect.Effect<string, McpConnectionError | StorageFailure> => {
207− if (typeof value === "string") return Effect.succeed(value);
208− return ctx.secrets.get(value.secretId).pipe(
209− Effect.mapError((err) =>
197+): Effect.Effect<Record<string, string> | undefined, McpConnectionError | StorageFailure> =>
198+ resolveSharedSecretBackedMap({
199+ values,
200+ getSecret: ctx.secrets.get,
201+ onMissing: (_name, value) =>
202+ remoteConnectionError(`Failed to resolve secret "${value.secretId}"`),
203+ onError: (err, _name, value) =>
210204 "_tag" in err && err._tag === "SecretOwnedByConnectionError"
211205 ? remoteConnectionError(`Failed to resolve secret "${value.secretId}"`)
212206 : err,
213− ),
214− Effect.flatMap((secret) =>
215− secret === null
216− ? Effect.fail(remoteConnectionError(`Failed to resolve secret "${value.secretId}"`))
217− : Effect.succeed(value.prefix ? `${value.prefix}${secret}` : secret),
218− ),
219− );
220−};
221−
222−const 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,
207+ }).pipe(
208+ Effect.mapError((err) =>
209+ "_tag" in err && err._tag === "SecretOwnedByConnectionError"
210+ ? remoteConnectionError("Failed to resolve secret")
211+ : err,
232212 ),
233213 );
234−};
235214
236215 const plainStringMap = (
237216 values: Record<string, SecretBackedValue> | undefined,
@@ -279,13 +258,15 @@ const resolveConnectorInput = ( · 41 unmodified lines
279258
280259 const auth = sd.auth;
281260 if (auth.kind === "header") {
282− const val = yield* ctx.secrets.get(auth.secretId).pipe(
283− Effect.mapError((err) =>
284− "_tag" in err && err._tag === "SecretOwnedByConnectionError"
285− ? remoteConnectionError(`Failed to resolve secret "${auth.secretId}"`)
286− : err,
287− ),
288− );
261+ const val = yield* ctx.secrets
262+ .get(auth.secretId)
263+ .pipe(
264+ Effect.mapError((err) =>
265+ "_tag" in err && err._tag === "SecretOwnedByConnectionError"
266+ ? remoteConnectionError(`Failed to resolve secret "${auth.secretId}"`)
267+ : err,
268+ ),
269+ );
289270 if (val === null) {
290271 return yield* Effect.fail(
291272 remoteConnectionError(`Failed to resolve secret "${auth.secretId}"`),
@@ -304,9 +285,7 @@ const resolveConnectorInput = ( · 12 unmodified lines
304285 Effect.mapError((err) =>
305286 remoteConnectionError(
306287 `Failed to resolve OAuth connection "${auth.connectionId}": ${
307− "message" in err
308− ? (err as { message: string }).message
309− : String(err)
288+ "message" in err ? (err as { message: string }).message : String(err)
310289 }`,
311290 ),
312291 ),
@@ -332,25 +311,15 @@ const resolveConnectorInput = ( · 19 unmodified lines
332311 // ---------------------------------------------------------------------------
333312
334313 interface McpRuntime {
335− readonly connectionCache: ScopedCache.ScopedCache<
336− string,
337− McpConnection,
338− McpConnectionError
339− >;
340− readonly pendingConnectors: Map<
341− string,
342− Effect.Effect<McpConnection, McpConnectionError>
343− >;
314+ readonly connectionCache: ScopedCache.ScopedCache<string, McpConnection, McpConnectionError>;
315+ readonly pendingConnectors: Map<string, Effect.Effect<McpConnection, McpConnectionError>>;
344316 readonly cacheScope: Scope.CloseableScope;
345317 }
346318
347319 const makeRuntime = (): Effect.Effect<McpRuntime, never> =>
348320 Effect.gen(function* () {
349321 const cacheScope = yield* Scope.make();
350− const pendingConnectors = new Map<
351− string,
352− Effect.Effect<McpConnection, McpConnectionError>
353− >();
322+ const pendingConnectors = new Map<string, Effect.Effect<McpConnection, McpConnectionError>>();
354323 const connectionCache = yield* ScopedCache.make({
355324 lookup: (key: string) =>
356325 Effect.acquireRelease(
@@ -366,8 +335,7 @@ const makeRuntime = (): Effect.Effect<McpRuntime, never> => · 9 unmodified lines
366335 }
367336 return connector;
368337 }),
369− (connection) =>
370− Effect.promise(() => connection.close().catch(() => {})),
338+ (connection) => Effect.promise(() => connection.close().catch(() => {})),
371339 ),
372340 capacity: 64,
373341 timeToLive: Duration.minutes(5),
@@ -444,540 +412,484 @@ const toMcpConfigEntry = ( · 70 unmodified lines
444412 return entry;
445413 };
446414
447−export const mcpPlugin = definePlugin(
448− (options?: McpPluginOptions) => {
449− const allowStdio = options?.dangerouslyAllowStdioMCP ?? false;
450− // Per-plugin-instance runtime holder. Captured by closures in
451− // `extension`, `invokeTool`, and `close`, so all three see the same
452− // connection cache across a single createExecutor lifecycle.
453− const runtimeRef: { current: McpRuntime | null } = { current: null };
454−
455− const ensureRuntime = (): Effect.Effect<McpRuntime, never> =>
456− runtimeRef.current
457− ? Effect.succeed(runtimeRef.current)
458− : makeRuntime().pipe(
459− Effect.tap((rt) =>
460− Effect.sync(() => {
461− runtimeRef.current = rt;
462− }),
463− ),
464− );
415+export const mcpPlugin = definePlugin((options?: McpPluginOptions) => {
416+ const allowStdio = options?.dangerouslyAllowStdioMCP ?? false;
417+ // Per-plugin-instance runtime holder. Captured by closures in
418+ // `extension`, `invokeTool`, and `close`, so all three see the same
419+ // connection cache across a single createExecutor lifecycle.
420+ const runtimeRef: { current: McpRuntime | null } = { current: null };
421+
422+ const ensureRuntime = (): Effect.Effect<McpRuntime, never> =>
423+ runtimeRef.current
424+ ? Effect.succeed(runtimeRef.current)
425+ : makeRuntime().pipe(
426+ Effect.tap((rt) =>
427+ Effect.sync(() => {
428+ runtimeRef.current = rt;
429+ }),
430+ ),
431+ );
465432
466− return {
467− id: "mcp" as const,
468− schema: mcpSchema,
469− 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− }
433+ return {
434+ id: "mcp" as const,
435+ schema: mcpSchema,
436+ storage: (deps): McpBindingStore => makeMcpStore(deps),
481437
482− const name = yield* Effect.try(
483− () => new URL(trimmed).hostname,
484− ).pipe(Effect.orElseSucceed(() => "mcp"));
485− const namespace = deriveMcpNamespace({ endpoint: trimmed });
486−
487− const probeHeaders =
488− typeof input === "string"
489− ? undefined
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− });
438+ extension: (ctx) => {
439+ const probeEndpoint = (input: string | McpProbeEndpointInput) =>
440+ Effect.gen(function* () {
441+ const endpoint = typeof input === "string" ? input : input.endpoint;
442+ const trimmed = endpoint.trim();
443+ if (!trimmed) {
444+ return yield* Effect.fail(remoteConnectionError("Endpoint URL is required"));
445+ }
502446
503− const result = yield* discoverTools(connector).pipe(
504− Effect.map((m) => ({ ok: true as const, manifest: m })),
505− Effect.catchAll(() =>
506− Effect.succeed({ ok: false as const, manifest: null }),
507− ),
508− Effect.withSpan("mcp.plugin.discover_tools"),
509− );
447+ const name = yield* Effect.try(() => new URL(trimmed).hostname).pipe(
448+ Effect.orElseSucceed(() => "mcp"),
449+ );
450+ const namespace = deriveMcpNamespace({ endpoint: trimmed });
510451
511− if (result.ok && result.manifest) {
512− return {
513− connected: true,
514− requiresOAuth: false,
515− name: result.manifest.server?.name ?? name,
516− namespace,
517− toolCount: result.manifest.tools.length,
518− serverName: result.manifest.server?.name ?? null,
519− } satisfies McpProbeResult;
520− }
452+ const probeHeaders =
453+ typeof input === "string"
454+ ? undefined
455+ : yield* resolveSecretBackedMap(input.headers, ctx);
456+ const probeQueryParams =
457+ typeof input === "string"
458+ ? undefined
459+ : yield* resolveSecretBackedMap(input.queryParams, ctx);
521460
522− // Before asking the core OAuth service to look for metadata,
523− // confirm the endpoint actually speaks MCP. An OAuth-protected
524− // non-MCP service (e.g. a GraphQL API whose host publishes
525− // RFC 9728 + 8414 metadata) would otherwise pass the OAuth
526− // probe and be misclassified as MCP. The shape probe rejects
527− // anything whose initialize response isn't 2xx or 401+Bearer.
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− }
461+ const connector = createMcpConnector({
462+ transport: "remote",
463+ endpoint: trimmed,
464+ headers: probeHeaders,
465+ queryParams: probeQueryParams,
466+ });
541467
542− const probeResult = yield* ctx.oauth
543− .probe({
544− endpoint: trimmed,
545− headers: probeHeaders,
546− queryParams: probeQueryParams,
547− })
548− .pipe(
549− Effect.map(() => true),
550− Effect.catchAll(() => Effect.succeed(false)),
551− Effect.withSpan("mcp.plugin.probe_oauth"),
552− );
468+ const result = yield* discoverTools(connector).pipe(
469+ Effect.map((m) => ({ ok: true as const, manifest: m })),
470+ Effect.catchAll(() => Effect.succeed({ ok: false as const, manifest: null })),
471+ Effect.withSpan("mcp.plugin.discover_tools"),
472+ );
553473
554− if (probeResult) {
555− return {
556− connected: false,
557− requiresOAuth: true,
558− name,
559− namespace,
560− toolCount: null,
561− serverName: null,
562− } satisfies McpProbeResult;
563− }
474+ if (result.ok && result.manifest) {
475+ return {
476+ connected: true,
477+ requiresOAuth: false,
478+ name: result.manifest.server?.name ?? name,
479+ namespace,
480+ toolCount: result.manifest.tools.length,
481+ serverName: result.manifest.server?.name ?? null,
482+ } satisfies McpProbeResult;
483+ }
564484
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") {
565496 return yield* Effect.fail(
566497 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}`,
568501 ),
569502 );
570− }).pipe(
571− Effect.withSpan("mcp.plugin.probe_endpoint", {
572− attributes: { "mcp.endpoint": typeof input === "string" ? input : input.endpoint },
573− }),
574− );
503+ }
575504
576− const configFile = options?.configFile;
577−
578− const addSource = (config: McpSourceConfig) =>
579− Effect.gen(function* () {
580− const namespace = normalizeNamespace(config);
581− const sd = toStoredSourceData(config);
582−
583− // Stdio sources are gated — a resolver failure there is a
584− // config error the admin must fix before the source makes
585− // sense to persist at all. For remote sources we defer the
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− }),
505+ const probeResult = yield* ctx.oauth
506+ .probe({
507+ endpoint: trimmed,
508+ headers: probeHeaders,
509+ queryParams: probeQueryParams,
510+ })
511+ .pipe(
512+ Effect.map(() => true),
513+ Effect.catchAll(() => Effect.succeed(false)),
514+ Effect.withSpan("mcp.plugin.probe_oauth"),
598515 );
599516
600− if (resolved._tag === "Left" && sd.transport === "stdio") {
601− return yield* Effect.fail(resolved.left);
602− }
517+ if (probeResult) {
518+ return {
519+ connected: false,
520+ requiresOAuth: true,
521+ name,
522+ namespace,
523+ toolCount: null,
524+ serverName: null,
525+ } satisfies McpProbeResult;
526+ }
603527
604− // Try discovery only if we have a live connector input.
605− // Otherwise fall straight through to the persist step with
606− // an empty manifest and surface the resolver failure to
607− // the caller at the end.
608− const discovery =
609− resolved._tag === "Right"
610− ? yield* discoverTools(createMcpConnector(resolved.right)).pipe(
611− Effect.mapError((err) =>
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− );
528+ return yield* Effect.fail(
529+ remoteConnectionError("MCP server requires authentication but OAuth discovery failed"),
530+ );
531+ }).pipe(
532+ Effect.withSpan("mcp.plugin.probe_endpoint", {
533+ attributes: { "mcp.endpoint": typeof input === "string" ? input : input.endpoint },
534+ }),
535+ );
681536
682− if (configFile) {
683− yield* configFile
684− .upsertSource(toMcpConfigEntry(namespace, sourceName, config))
685− .pipe(Effect.withSpan("mcp.plugin.config_file.upsert"));
686− }
537+ const configFile = options?.configFile;
687538
688− if (discovery._tag === "Left") {
689− return yield* Effect.fail(discovery.left);
690− }
691− return { toolCount: manifest.tools.length, namespace };
692− }).pipe(
693− Effect.withSpan("mcp.plugin.add_source", {
539+ const addSource = (config: McpSourceConfig) =>
540+ Effect.gen(function* () {
541+ const namespace = normalizeNamespace(config);
542+ const sd = toStoredSourceData(config);
543+
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", {
694554 attributes: {
695− "mcp.source.transport": config.transport,
696− "mcp.source.name": config.name,
555+ "mcp.source.namespace": namespace,
556+ "mcp.source.transport": sd.transport,
697557 },
698558 }),
699559 );
700560
701− const removeSource = (namespace: string, scope: string) =>
702− Effect.gen(function* () {
703− yield* ctx
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− );
561+ if (resolved._tag === "Left" && sd.transport === "stdio") {
562+ return yield* Effect.fail(resolved.left);
563+ }
722564
723− const refreshSource = (namespace: string, scope: string) =>
724− Effect.gen(function* () {
725− const sd = yield* ctx.storage.getSourceConfig(namespace, scope).pipe(
726− Effect.withSpan("mcp.plugin.load_source_config", {
727− attributes: { "mcp.source.namespace": namespace },
565+ // Try discovery only if we have a live connector input.
566+ // Otherwise fall straight through to the persist step with
567+ // an empty manifest and surface the resolver failure to
568+ // the caller at the end.
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+ });
728629 }),
729− );
730− if (!sd) {
731− return yield* Effect.fail(
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", {
630+ )
631+ .pipe(
632+ Effect.withSpan("mcp.plugin.persist_source", {
740633 attributes: {
741634 "mcp.source.namespace": namespace,
742− "mcp.source.transport": sd.transport,
635+ "mcp.source.tool_count": manifest.tools.length,
743636 },
744637 }),
745638 );
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− );
754639
755− const existing = yield* ctx.storage.getSource(namespace, scope);
756− const sourceName =
757− manifest.server?.name ?? existing?.name ?? namespace;
758−
759− yield* ctx
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− );
640+ if (configFile) {
641+ yield* configFile
642+ .upsertSource(toMcpConfigEntry(namespace, sourceName, config))
643+ .pipe(Effect.withSpan("mcp.plugin.config_file.upsert"));
644+ }
806645
807− const updateSource = (
808− namespace: string,
809− scope: string,
810− input: McpUpdateSourceInput,
811− ) =>
812− Effect.gen(function* () {
813− const existing = yield* ctx.storage.getSource(namespace, scope);
814− if (!existing || existing.config.transport !== "remote") return;
815−
816− const remote = existing.config;
817− const updatedConfig: McpStoredSourceData = {
818− ...remote,
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− );
646+ if (discovery._tag === "Left") {
647+ return yield* Effect.fail(discovery.left);
648+ }
649+ return { toolCount: manifest.tools.length, namespace };
650+ }).pipe(
651+ Effect.withSpan("mcp.plugin.add_source", {
652+ attributes: {
653+ "mcp.source.transport": config.transport,
654+ "mcp.source.name": config.name,
655+ },
656+ }),
657+ );
838658
839− const getSource = (namespace: string, scope: string) =>
840− ctx.storage.getSource(namespace, scope).pipe(
841− Effect.withSpan("mcp.plugin.get_source", {
842− attributes: { "mcp.source.namespace": namespace },
843− }),
844− );
659+ const removeSource = (namespace: string, scope: string) =>
660+ Effect.gen(function* () {
661+ yield* ctx
662+ .transaction(
663+ Effect.gen(function* () {
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+ );
845680
846− return {
847− probeEndpoint,
848− addSource,
849− removeSource,
850− refreshSource,
851− getSource,
852− updateSource,
853− } satisfies McpPluginExtension;
854− },
855−
856− invokeTool: ({ ctx, toolRow, args, elicit }) =>
681+ const refreshSource = (namespace: string, scope: string) =>
857682 Effect.gen(function* () {
858− const runtime = yield* ensureRuntime();
859−
860− // toolRow.scope_id is the resolved owning scope of the tool
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 },
683+ const sd = yield* ctx.storage.getSourceConfig(namespace, scope).pipe(
684+ Effect.withSpan("mcp.plugin.load_source_config", {
685+ attributes: { "mcp.source.namespace": namespace },
869686 }),
870687 );
871− if (!entry) {
688+ if (!sd) {
872689 return yield* Effect.fail(
873− new Error(`No MCP binding found for tool "${toolRow.id}"`),
690+ remoteConnectionError(`No stored config for MCP source "${namespace}"`),
874691 );
875692 }
876693
877− const sd = yield* ctx.storage.getSourceConfig(entry.namespace, toolScope).pipe(
878− Effect.withSpan("mcp.plugin.load_source_config", {
879− attributes: { "mcp.source.namespace": entry.namespace },
694+ const ci = yield* resolveConnectorInput(sd, ctx, allowStdio).pipe(
695+ Effect.withSpan("mcp.plugin.resolve_connector", {
696+ attributes: {
697+ "mcp.source.namespace": namespace,
698+ "mcp.source.transport": sd.transport,
699+ },
880700 }),
881701 );
882− if (!sd) {
883− return yield* Effect.fail(
884− new Error(
885− `No MCP source config for namespace "${entry.namespace}"`,
886− ),
702+ const manifest = yield* discoverTools(createMcpConnector(ci)).pipe(
703+ Effect.mapError((err) => mcpDiscoveryError(`MCP refresh failed: ${err.message}`)),
704+ Effect.withSpan("mcp.plugin.discover_tools", {
705+ attributes: { "mcp.source.namespace": namespace },
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+ }),
887751 );
888− }
889752
890− return yield* invokeMcpTool({
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− });
753+ return { toolCount: manifest.tools.length };
919754 }).pipe(
920− Effect.withSpan("mcp.plugin.invoke_tool", {
921− attributes: {
922− "mcp.tool.name": toolRow.id,
923− "mcp.tool.source_id": toolRow.source_id,
924− },
755+ Effect.withSpan("mcp.plugin.refresh_source", {
756+ attributes: { "mcp.source.namespace": namespace },
925757 }),
926− ),
758+ );
927759
928− detect: ({ ctx, url }) =>
760+ const updateSource = (namespace: string, scope: string, input: McpUpdateSourceInput) =>
929761 Effect.gen(function* () {
930− const trimmed = url.trim();
931− if (!trimmed) return null;
762+ const existing = yield* ctx.storage.getSource(namespace, scope);
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+ }
932821
933− const parsed = yield* Effect.try(() => new URL(trimmed)).pipe(
934− Effect.option,
822+ const sd = yield* ctx.storage.getSourceConfig(entry.namespace, toolScope).pipe(
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}"`),
935830 );
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+ ),
937869
938− const name = parsed.value.hostname || "mcp";
939− const namespace = deriveMcpNamespace({ endpoint: trimmed });
870+ detect: ({ ctx, url }) =>
871+ Effect.gen(function* () {
872+ const trimmed = url.trim();
873+ if (!trimmed) return null;
940874
941− const connector = createMcpConnector({
942− transport: "remote",
943− endpoint: trimmed,
944− });
875+ const parsed = yield* Effect.try(() => new URL(trimmed)).pipe(Effect.option);
876+ if (parsed._tag === "None") return null;
945877
946− const connected = yield* discoverTools(connector).pipe(
947− Effect.map(() => true),
948− Effect.catchAll(() => Effect.succeed(false)),
949− Effect.withSpan("mcp.plugin.discover_tools"),
950− );
878+ const name = parsed.value.hostname || "mcp";
879+ const namespace = deriveMcpNamespace({ endpoint: trimmed });
951880
952− if (connected) {
953− return new SourceDetectionResult({
954− kind: "mcp",
955− confidence: "high",
956− endpoint: trimmed,
957− name,
958− namespace,
959− });
960− }
881+ const connector = createMcpConnector({
882+ transport: "remote",
883+ endpoint: trimmed,
884+ });
961885
962− // host publishes RFC 9728 + 8414 metadata) would be classified
963− // as MCP whenever the cross-plugin detector fans out to us.
964− const shape = yield* probeMcpEndpointShape(trimmed);
965− if (shape.kind !== "mcp") return null;
966−
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;
886+ const connected = yield* discoverTools(connector).pipe(
887+ Effect.map(() => true),
888+ Effect.catchAll(() => Effect.succeed(false)),
889+ Effect.withSpan("mcp.plugin.discover_tools"),
890+ );
980891
892+ if (connected) {
981893 return new SourceDetectionResult({
982894 kind: "mcp",
983895 confidence: "high",
@@ -985,49 +897,74 @@ export const mcpPlugin = definePlugin( · 1 unmodified line
985897 name,
986898 namespace,
987899 });
988− }).pipe(
989− Effect.catchAll(() => Effect.succeed(null)),
990− Effect.withSpan("mcp.plugin.detect", {
991− attributes: { "mcp.endpoint": url },
992− }),
993− ),
994−
995− // MCP tools never require approval at the tool level — elicitation is
996− // handled mid-invocation by the server via the elicit capability.
997− resolveAnnotations: ({ toolRows }) =>
998− Effect.sync(() => {
999− const out: Record<string, { requiresApproval: boolean }> = {};
1000− for (const row of toolRows) {
1001− out[row.id] = { requiresApproval: false };
1002− }
1003− return out;
1004− }),
1005−
1006− removeSource: ({ ctx, sourceId, scope }) =>
1007− Effect.gen(function* () {
1008− yield* ctx.storage.removeBindingsByNamespace(sourceId, scope);
1009− yield* ctx.storage.removeSource(sourceId, scope);
900+ }
901+
902+ // host publishes RFC 9728 + 8414 metadata) would be classified
903+ // as MCP whenever the cross-plugin detector fans out to us.
904+ const shape = yield* probeMcpEndpointShape(trimmed);
905+ if (shape.kind !== "mcp") return null;
906+
907+ // Confirm OAuth metadata is actually reachable. The shape
908+ // probe already found a Bearer challenge; the core OAuth
909+ // service's probe verifies the AS metadata resolves so we
910+ // don't classify endpoints that challenge but have no
911+ // discovery.
912+ const probeOk = yield* ctx.oauth.probe({ endpoint: trimmed }).pipe(
913+ Effect.map(() => true),
914+ Effect.catchAll(() => Effect.succeed(false)),
915+ Effect.withSpan("mcp.plugin.probe_oauth"),
916+ );
917+ if (!probeOk) return null;
918+
919+ return new SourceDetectionResult({
920+ kind: "mcp",
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 },
1010930 }),
1011−
1012− refreshSource: () => Effect.void,
1013−
1014− // Connection refresh for oauth2-minted sources is owned by the
1015− // canonical `"oauth2"` ConnectionProvider that core registers via
1016− // `makeOAuth2Service`. No MCP-specific provider needed.
1017−
1018− close: () =>
1019− Effect.gen(function* () {
1020− const runtime = runtimeRef.current;
1021− if (runtime) {
1022− runtime.pendingConnectors.clear();
1023− yield* runtime.connectionCache.invalidateAll;
1024− yield* Scope.close(runtime.cacheScope, Exit.void);
1025− runtimeRef.current = null;
1026− }
1027− }).pipe(Effect.withSpan("mcp.plugin.close")),
1028− };
1029− },
1030−);
931+ ),
932+
933+ // MCP tools never require approval at the tool level — elicitation is
934+ // handled mid-invocation by the server via the elicit capability.
935+ resolveAnnotations: ({ toolRows }) =>
936+ Effect.sync(() => {
937+ const out: Record<string, { requiresApproval: boolean }> = {};
938+ for (const row of toolRows) {
939+ out[row.id] = { requiresApproval: false };
940+ }
941+ return out;
942+ }),
943+
944+ removeSource: ({ ctx, sourceId, scope }) =>
945+ Effect.gen(function* () {
946+ yield* ctx.storage.removeBindingsByNamespace(sourceId, scope);
947+ yield* ctx.storage.removeSource(sourceId, scope);
948+ }),
949+
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+});
1031968
1032969 // ---------------------------------------------------------------------------
1033970 // McpPluginExtension — shape of `executor.mcp` for consumers that want
@@ -1045,10 +982,7 @@ export const mcpPlugin = definePlugin( · 11 unmodified lines
1045982 * composition. `UniqueViolationError` passes through — plugins can
1046983 * `Effect.catchTag` it if they want a friendlier user-facing error.
1047984 */
1048−export type McpExtensionFailure =
1049− | McpConnectionError
1050− | McpToolDiscoveryError
1051− | StorageFailure;
985+export type McpExtensionFailure = McpConnectionError | McpToolDiscoveryError | StorageFailure;
1052986
1053987 export interface McpPluginExtension {
1054988 readonly probeEndpoint: (
packages/plugins/mcp/src/sdk/types.ts+3−10
@@ -1,4 +1,7 @@
11 import { Schema } from "effect";
2+import { SecretBackedMap, SecretBackedValue } from "@executor/sdk";
3+
4+export { SecretBackedMap, SecretBackedValue };
25
36 // ---------------------------------------------------------------------------
47 // Remote transport type
@@ -46,18 +49,8 @@ export type McpConnectionAuth = typeof McpConnectionAuth.Type; · 41 unmodified lines
4649 // Stored source data — discriminated union on transport
4750 // ---------------------------------------------------------------------------
4851
49−export const SecretBackedValue = Schema.Union(
50− Schema.String,
51− Schema.Struct({
52− secretId: Schema.String,
53− prefix: Schema.optional(Schema.String),
54− }),
55−);
56−export type SecretBackedValue = typeof SecretBackedValue.Type;
57−
5852 /** Common fields for remote string map schemas */
5953 const StringMap = Schema.Record({ key: Schema.String, value: Schema.String });
60−const SecretBackedMap = Schema.Record({ key: Schema.String, value: SecretBackedValue });
6154
6255 export const McpRemoteSourceData = Schema.Struct({
6356 transport: Schema.Literal("remote"),
packages/plugins/openapi/src/api/group.ts+1−9
@@ -1,6 +1,6 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import { Schema } from "effect";
3−import { ScopeId } from "@executor/sdk";
3+import { ScopeId, SecretBackedValue } from "@executor/sdk";
44 import { InternalError } from "@executor/api";
55
66 import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors";
@@ -21,14 +21,6 @@ const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId); · 14 unmodified lines
2121 const namespaceParam = HttpApiSchema.param("namespace", Schema.String);
2222 const sourceScopeIdParam = HttpApiSchema.param("sourceScopeId", ScopeId);
2323
24−const SecretBackedValue = Schema.Union(
25− Schema.String,
26− Schema.Struct({
27− secretId: Schema.String,
28− prefix: Schema.optional(Schema.String),
29− }),
30−);
31−
3224 const SpecFetchCredentialsPayload = Schema.Struct({
3325 headers: Schema.optional(Schema.Record({ key: Schema.String, value: SecretBackedValue })),
3426 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
88 SecretId,
99 SourceDetectionResult,
1010 definePlugin,
11+ resolveSecretBackedMap,
1112 type PluginCtx,
1213 type StorageFailure,
1314 type ToolAnnotations,
@@ -471,39 +472,31 @@ const resolveConfiguredHeaders = ( · 457 unmodified lines
471472 return resolved;
472473 });
473474
474−const resolveHeaderValue = (
475+const resolveHeaderValues = (
475476 ctx: PluginCtx<OpenapiStore>,
476− name: string,
477− value: HeaderValue,
478−): Effect.Effect<string, OpenApiOAuthError | StorageFailure> => {
479− if (typeof value === "string") return Effect.succeed(value);
480− return ctx.secrets.get(value.secretId).pipe(
481− Effect.mapError((err) =>
477+ values: Record<string, HeaderValue> | undefined,
478+): Effect.Effect<Record<string, string>, OpenApiOAuthError | StorageFailure> =>
479+ resolveSecretBackedMap({
480+ values,
481+ getSecret: ctx.secrets.get,
482+ onMissing: (name) =>
483+ new OpenApiOAuthError({
484+ message: `Secret not found for "${name}"`,
485+ }),
486+ onError: (err, name) =>
482487 "_tag" in err && err._tag === "SecretOwnedByConnectionError"
483488 ? new OpenApiOAuthError({
484489 message: `Secret not found for "${name}"`,
485490 })
486491 : err,
492+ }).pipe(
493+ Effect.mapError((err) =>
494+ "_tag" in err && err._tag === "SecretOwnedByConnectionError"
495+ ? new OpenApiOAuthError({ message: "Secret resolution failed" })
496+ : err,
487497 ),
488− Effect.flatMap((secret) =>
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− ),
498+ Effect.map((resolved) => resolved ?? {}),
497499 );
498−};
499−
500−const 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)));
507500
508501 const resolveOAuthConnectionId = (
509502 ctx: PluginCtx<OpenapiStore>,
packages/plugins/openapi/src/sdk/types.ts+6−16
@@ -1,5 +1,5 @@
11 import { Schema } from "effect";
2−import { ConnectionId, ScopeId, SecretId } from "@executor/sdk";
2+import { ConnectionId, ScopeId, SecretBackedValue, SecretId } from "@executor/sdk";
33
44 // ---------------------------------------------------------------------------
55 // Branded IDs
@@ -61,10 +61,9 @@ export class EncodingObject extends Schema.Class<EncodingObject>("EncodingObject · 55 unmodified lines
6161 export class MediaBinding extends Schema.Class<MediaBinding>("MediaBinding")({
6262 contentType: Schema.String,
6363 schema: Schema.optionalWith(Schema.Unknown, { as: "Option" }),
64− encoding: Schema.optionalWith(
65− Schema.Record({ key: Schema.String, value: EncodingObject }),
66− { as: "Option" },
67− ),
64+ encoding: Schema.optionalWith(Schema.Record({ key: Schema.String, value: EncodingObject }), {
65+ as: "Option",
66+ }),
6867 }) {}
6968
7069 export class OperationRequestBody extends Schema.Class<OperationRequestBody>(
@@ -137,13 +136,7 @@ export class OperationBinding extends Schema.Class<OperationBinding>("OperationB · 66 unmodified lines
137136 * A header value — either a static string or a reference to a secret.
138137 * Stored as JSON-serializable data.
139138 */
140−export const HeaderValue = Schema.Union(
141− Schema.String,
142− Schema.Struct({
143− secretId: Schema.String,
144− prefix: Schema.optional(Schema.String),
145− }),
146−);
139+export const HeaderValue = SecretBackedValue;
147140 export type HeaderValue = typeof HeaderValue.Type;
148141
149142 export class ConfiguredHeaderBinding extends Schema.Class<ConfiguredHeaderBinding>(
@@ -154,10 +147,7 @@ export class ConfiguredHeaderBinding extends Schema.Class<ConfiguredHeaderBindin · 4 unmodified lines
154147 prefix: Schema.optional(Schema.String),
155148 }) {}
156149
157−export const ConfiguredHeaderValue = Schema.Union(
158− Schema.String,
159− ConfiguredHeaderBinding,
160−);
150+export const ConfiguredHeaderValue = Schema.Union(Schema.String, ConfiguredHeaderBinding);
161151 export type ConfiguredHeaderValue = typeof ConfiguredHeaderValue.Type;
162152
163153 export const OpenApiSourceBindingValue = Schema.Union(
packages/react/src/plugins/http-credentials.tsx+2−7
@@ -1,6 +1,6 @@
11 import { useId } from "react";
22 import { PlusIcon } from "lucide-react";
3−import type { ScopeId } from "@executor/sdk";
3+import type { ScopeId, SecretBackedValue } from "@executor/sdk";
44
55 import { Button } from "../components/button";
66 import { CardStack, CardStackContent, CardStackEntry } from "../components/card-stack";
@@ -15,12 +15,7 @@ import { · 8 unmodified lines
1515 } from "./secret-header-auth";
1616 import type { SecretPickerSecret } from "./secret-picker";
1717
18−export type SecretBackedValue =
19− | string
20− | {
21− readonly secretId: string;
22− readonly prefix?: string;
23− };
18+export type { SecretBackedValue };
2419
2520 export type QueryParamState = {
2621 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
55 import { openOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup";
66 import { useScope } from "../api/scope-context";
77 import { Button } from "../components/button";
8−import { OAUTH_POPUP_MESSAGE_TYPE, type OAuthStrategy } from "@executor/sdk";
9−
10−type SecretBackedValue =
11− | string
12− | {
13− readonly secretId: string;
14− readonly prefix?: string;
15− };
8+import {
9+ OAUTH_POPUP_MESSAGE_TYPE,
10+ type OAuthStrategy,
11+ type SecretBackedValue,
12+} from "@executor/sdk";
1613
1714 export type OAuthCompletionPayload = {
1815 readonly connectionId: string;