Unified · Split
| @@ -14,8 +14,10 @@ import { · 13 unmodified lines | ||
| 14 | 14 | OAuthProbeError, |
| 15 | 15 | OAuthSessionNotFoundError, |
| 16 | 16 | OAuthStartError, |
| 17 | + resolveSecretBackedMap, | |
| 17 | 18 | type Executor, |
| 18 | 19 | type OAuthStrategy, |
| 20 | + type SecretBackedValue, | |
| 19 | 21 | } from "@executor/sdk"; |
| 20 | 22 | |
| 21 | 23 | import { ExecutorApi } from "../api"; |
| @@ -24,32 +26,23 @@ import { ExecutorService } from "../services"; · 2 unmodified lines | ||
| 24 | 26 | |
| 25 | 27 | const OAUTH_POPUP_CHANNEL = OAUTH_POPUP_MESSAGE_TYPE; |
| 26 | 28 | |
| 27 | −type SecretBackedValue = | |
| 28 | − | string | |
| 29 | − | { readonly secretId: string; readonly prefix?: string }; | |
| 30 | − | |
| 31 | −const resolveSecretBackedMap = ( | |
| 29 | +const resolveOAuthSecretBackedMap = ( | |
| 32 | 30 | executor: Executor, |
| 33 | 31 | values: Record<string, SecretBackedValue> | undefined, |
| 34 | 32 | makeError: (message: string) => OAuthProbeError | OAuthStartError, |
| 35 | 33 | ) => |
| 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 | + ); | |
| 53 | 46 | |
| 54 | 47 | const toPopupErrorMessage = (error: unknown): string => { |
| 55 | 48 | if (error instanceof OAuthStartError) return error.message; |
| @@ -67,12 +60,12 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 11 unmodified lines | ||
| 67 | 60 | capture( |
| 68 | 61 | Effect.gen(function* () { |
| 69 | 62 | const executor = yield* ExecutorService; |
| 70 | − const headers = yield* resolveSecretBackedMap( | |
| 63 | + const headers = yield* resolveOAuthSecretBackedMap( | |
| 71 | 64 | executor, |
| 72 | 65 | payload.headers, |
| 73 | 66 | (message) => new OAuthProbeError({ message }), |
| 74 | 67 | ); |
| 75 | − const queryParams = yield* resolveSecretBackedMap( | |
| 68 | + const queryParams = yield* resolveOAuthSecretBackedMap( | |
| 76 | 69 | executor, |
| 77 | 70 | payload.queryParams, |
| 78 | 71 | (message) => new OAuthProbeError({ message }), |
| @@ -89,14 +82,13 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 10 unmodified lines | ||
| 89 | 82 | capture( |
| 90 | 83 | Effect.gen(function* () { |
| 91 | 84 | 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( | |
| 95 | 87 | executor, |
| 96 | 88 | payload.headers, |
| 97 | 89 | (message) => new OAuthStartError({ message }), |
| 98 | 90 | ); |
| 99 | − const queryParams = yield* resolveSecretBackedMap( | |
| 91 | + const queryParams = yield* resolveOAuthSecretBackedMap( | |
| 100 | 92 | executor, |
| 101 | 93 | payload.queryParams, |
| 102 | 94 | (message) => new OAuthStartError({ message }), |
| @@ -144,18 +136,18 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 41 unmodified lines | ||
| 144 | 136 | const executor = yield* ExecutorService; |
| 145 | 137 | const html = yield* runOAuthCallback({ |
| 146 | 138 | 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"))), | |
| 157 | 150 | ), |
| 158 | − ), | |
| 159 | 151 | urlParams, |
| 160 | 152 | toErrorMessage: toPopupErrorMessage, |
| 161 | 153 | channelName: OAUTH_POPUP_CHANNEL, |
| @@ -17,20 +17,12 @@ import { · 16 unmodified lines | ||
| 17 | 17 | OAuthStartError, |
| 18 | 18 | OAuthStrategySchema, |
| 19 | 19 | ScopeId, |
| 20 | + SecretBackedMap, | |
| 20 | 21 | } from "@executor/sdk"; |
| 21 | 22 | |
| 22 | 23 | import { InternalError } from "../observability"; |
| 23 | 24 | |
| 24 | 25 | 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 | − | |
| 34 | 26 | // --------------------------------------------------------------------------- |
| 35 | 27 | // 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 | |
| 44 | 36 | 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 })), | |
| 48 | 38 | resourceMetadataUrl: Schema.NullOr(Schema.String), |
| 49 | 39 | authorizationServerMetadata: Schema.NullOr( |
| 50 | 40 | Schema.Record({ key: Schema.String, value: Schema.Unknown }), |
| @@ -91,9 +81,7 @@ const StartResponse = Schema.Struct({ · 40 unmodified lines | ||
| 91 | 81 | * `client-credentials` (no redirect). */ |
| 92 | 82 | authorizationUrl: Schema.NullOr(Schema.String), |
| 93 | 83 | /** 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 })), | |
| 97 | 85 | }); |
| 98 | 86 | |
| 99 | 87 | // --------------------------------------------------------------------------- |
| @@ -93,11 +93,15 @@ export { · 92 unmodified lines | ||
| 93 | 93 | } from "./policies"; |
| 94 | 94 | |
| 95 | 95 | // Secrets |
| 96 | +export { SecretRef, SetSecretInput, type SecretProvider } from "./secrets"; | |
| 97 | + | |
| 96 | 98 | 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"; | |
| 101 | 105 | |
| 102 | 106 | // Connections |
| 103 | 107 | export { |
| @@ -236,22 +240,13 @@ export { · 132 unmodified lines | ||
| 236 | 240 | } from "./executor"; |
| 237 | 241 | |
| 238 | 242 | // CLI config |
| 239 | −export { | |
| 240 | − defineExecutorConfig, | |
| 241 | − type ExecutorCliConfig, | |
| 242 | − type ExecutorDialect, | |
| 243 | −} from "./config"; | |
| 243 | +export { defineExecutorConfig, type ExecutorCliConfig, type ExecutorDialect } from "./config"; | |
| 244 | 244 | |
| 245 | 245 | // Test helper |
| 246 | 246 | export { makeTestConfig } from "./testing"; |
| 247 | 247 | |
| 248 | 248 | // 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"; | |
| 255 | 250 | |
| 256 | 251 | // TypeScript preview generation from JSON schemas |
| 257 | 252 | export { |
| @@ -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 | +}; | |
| @@ -1,12 +1,9 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | −import { ScopeId } from "@executor/sdk"; | |
| 3 | +import { ScopeId, SecretBackedValue } from "@executor/sdk"; | |
| 4 | 4 | import { InternalError } from "@executor/api"; |
| 5 | 5 | |
| 6 | −import { | |
| 7 | − GoogleDiscoveryParseError, | |
| 8 | − GoogleDiscoverySourceError, | |
| 9 | −} from "../sdk/errors"; | |
| 6 | +import { GoogleDiscoveryParseError, GoogleDiscoverySourceError } from "../sdk/errors"; | |
| 10 | 7 | import { GoogleDiscoveryStoredSourceSchema } from "../sdk/stored-source"; |
| 11 | 8 | |
| 12 | 9 | export { HttpApiSchema }; |
| @@ -14,14 +11,6 @@ export { HttpApiSchema }; · 1 unmodified line | ||
| 14 | 11 | const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId); |
| 15 | 12 | const namespaceParam = HttpApiSchema.param("namespace", Schema.String); |
| 16 | 13 | |
| 17 | −const SecretBackedValue = Schema.Union( | |
| 18 | − Schema.String, | |
| 19 | − Schema.Struct({ | |
| 20 | − secretId: Schema.String, | |
| 21 | − prefix: Schema.optional(Schema.String), | |
| 22 | − }), | |
| 23 | −); | |
| 24 | − | |
| 25 | 14 | const DiscoveryCredentialsPayload = Schema.Struct({ |
| 26 | 15 | headers: Schema.optional(Schema.Record({ key: Schema.String, value: SecretBackedValue })), |
| 27 | 16 | queryParams: Schema.optional(Schema.Record({ key: Schema.String, value: SecretBackedValue })), |
| @@ -3,6 +3,7 @@ import { Effect, Option } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | import { |
| 4 | 4 | SourceDetectionResult, |
| 5 | 5 | definePlugin, |
| 6 | + resolveSecretBackedMap, | |
| 6 | 7 | type PluginCtx, |
| 7 | 8 | type StorageFailure, |
| 8 | 9 | type ToolAnnotations, |
| @@ -16,10 +17,7 @@ import { · 7 unmodified lines | ||
| 16 | 17 | } from "./binding-store"; |
| 17 | 18 | import { extractGoogleDiscoveryManifest } from "./document"; |
| 18 | 19 | import { annotationsForOperation, invokeGoogleDiscoveryTool } from "./invoke"; |
| 19 | −import { | |
| 20 | − GoogleDiscoveryParseError, | |
| 21 | − GoogleDiscoverySourceError, | |
| 22 | −} from "./errors"; | |
| 20 | +import { GoogleDiscoveryParseError, GoogleDiscoverySourceError } from "./errors"; | |
| 23 | 21 | import type { |
| 24 | 22 | GoogleDiscoveryAuth, |
| 25 | 23 | GoogleDiscoveryFetchCredentials, |
| @@ -93,10 +91,7 @@ export interface GoogleDiscoveryPluginExtension { · 67 unmodified lines | ||
| 93 | 91 | { readonly toolCount: number; readonly namespace: string }, |
| 94 | 92 | GoogleDiscoveryParseError | GoogleDiscoverySourceError | StorageFailure |
| 95 | 93 | >; |
| 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>; | |
| 100 | 95 | readonly getSource: ( |
| 101 | 96 | namespace: string, |
| 102 | 97 | scope: string, |
| @@ -143,57 +138,50 @@ const resolveGoogleDiscoveryCredentials = ( · 40 unmodified lines | ||
| 143 | 138 | credentials: GoogleDiscoveryFetchCredentials | undefined, |
| 144 | 139 | ctx: PluginCtx<GoogleDiscoveryStore>, |
| 145 | 140 | ): Effect.Effect< |
| 146 | − | { headers?: Record<string, string>; queryParams?: Record<string, string> } | |
| 147 | − | undefined, | |
| 141 | + { headers?: Record<string, string>; queryParams?: Record<string, string> } | undefined, | |
| 148 | 142 | GoogleDiscoverySourceError |
| 149 | 143 | > => |
| 150 | 144 | Effect.gen(function* () { |
| 151 | 145 | 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({ | |
| 168 | 151 | 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({ | |
| 189 | 169 | 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 | + ); | |
| 194 | 182 | return { |
| 195 | − ...(Object.keys(headers).length > 0 ? { headers } : {}), | |
| 196 | − ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}), | |
| 183 | + ...(headers ? { headers } : {}), | |
| 184 | + ...(queryParams ? { queryParams } : {}), | |
| 197 | 185 | }; |
| 198 | 186 | }); |
| 199 | 187 | |
| @@ -207,9 +195,7 @@ const fetchDiscoveryDocument = ( · 7 unmodified lines | ||
| 207 | 195 | Effect.tryPromise({ |
| 208 | 196 | try: async () => { |
| 209 | 197 | 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 ?? {})) { | |
| 213 | 199 | url.searchParams.set(key, value); |
| 214 | 200 | } |
| 215 | 201 | const response = await fetch(url.toString(), { |
| @@ -237,14 +223,9 @@ const normalizeSlug = (value: string): string => · 21 unmodified lines | ||
| 237 | 223 | .replace(/[^a-z0-9]+/g, "_") |
| 238 | 224 | .replace(/^_+|_+$/g, ""); |
| 239 | 225 | |
| 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 => | |
| 245 | 227 | 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, "_")}`, | |
| 248 | 229 | ) || `google_${input.service}`; |
| 249 | 230 | |
| 250 | 231 | // Connection refresh state is owned by the canonical `"oauth2"` |
| @@ -281,8 +262,7 @@ const registerManifest = ( · 30 unmodified lines | ||
| 281 | 262 | name: method.toolPath, |
| 282 | 263 | description: Option.getOrElse( |
| 283 | 264 | method.description, |
| 284 | − () => | |
| 285 | − `${method.binding.method.toUpperCase()} ${method.binding.pathTemplate}`, | |
| 265 | + () => `${method.binding.method.toUpperCase()} ${method.binding.pathTemplate}`, | |
| 286 | 266 | ), |
| 287 | 267 | inputSchema: Option.getOrUndefined(method.inputSchema), |
| 288 | 268 | outputSchema: Option.getOrUndefined(method.outputSchema), |
| @@ -300,12 +280,7 @@ const registerManifest = ( · 11 unmodified lines | ||
| 300 | 280 | yield* Effect.forEach( |
| 301 | 281 | manifest.methods, |
| 302 | 282 | (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), | |
| 309 | 284 | { discard: true }, |
| 310 | 285 | ); |
| 311 | 286 | |
| @@ -332,30 +307,21 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 20 unmodified lines | ||
| 332 | 307 | ({ |
| 333 | 308 | probeDiscovery: (input) => |
| 334 | 309 | Effect.gen(function* () { |
| 335 | − const discoveryUrl = | |
| 336 | − typeof input === "string" ? input : input.discoveryUrl; | |
| 310 | + const discoveryUrl = typeof input === "string" ? input : input.discoveryUrl; | |
| 337 | 311 | const credentials = |
| 338 | 312 | typeof input === "string" |
| 339 | 313 | ? undefined |
| 340 | − : yield* resolveGoogleDiscoveryCredentials( | |
| 341 | − input.credentials, | |
| 342 | − ctx, | |
| 343 | − ); | |
| 314 | + : yield* resolveGoogleDiscoveryCredentials(input.credentials, ctx); | |
| 344 | 315 | const text = yield* fetchDiscoveryDocument(discoveryUrl, credentials); |
| 345 | 316 | const manifest = yield* extractGoogleDiscoveryManifest(text); |
| 346 | 317 | const scopes = Object.keys( |
| 347 | − manifest.oauthScopes._tag === "Some" | |
| 348 | − ? manifest.oauthScopes.value | |
| 349 | − : {}, | |
| 318 | + manifest.oauthScopes._tag === "Some" ? manifest.oauthScopes.value : {}, | |
| 350 | 319 | ).sort(); |
| 351 | 320 | const operations = manifest.methods.map((method) => ({ |
| 352 | 321 | toolPath: method.toolPath, |
| 353 | 322 | method: method.binding.method, |
| 354 | 323 | 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, | |
| 359 | 325 | })); |
| 360 | 326 | return { |
| 361 | 327 | name: |
| @@ -374,14 +340,8 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 12 unmodified lines | ||
| 374 | 340 | addSource: (input) => |
| 375 | 341 | ctx.transaction( |
| 376 | 342 | 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); | |
| 385 | 345 | const manifest = yield* extractGoogleDiscoveryManifest(text); |
| 386 | 346 | const namespace = |
| 387 | 347 | input.namespace ?? |
| @@ -447,25 +407,16 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 59 unmodified lines | ||
| 447 | 407 | const typedCtx = ctx as PluginCtx<GoogleDiscoveryStore>; |
| 448 | 408 | const scopes = new Set<string>(); |
| 449 | 409 | 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>>(); | |
| 454 | 411 | 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); | |
| 459 | 413 | byScope.set(scope, bindings); |
| 460 | 414 | } |
| 461 | 415 | const out: Record<string, ToolAnnotations> = {}; |
| 462 | 416 | for (const row of toolRows) { |
| 463 | 417 | const binding = byScope.get(row.scope_id as string)?.get(row.id); |
| 464 | 418 | if (binding) { |
| 465 | − out[row.id] = annotationsForOperation( | |
| 466 | − binding.method, | |
| 467 | − binding.pathTemplate, | |
| 468 | − ); | |
| 419 | + out[row.id] = annotationsForOperation(binding.method, binding.pathTemplate); | |
| 469 | 420 | } |
| 470 | 421 | } |
| 471 | 422 | return out; |
| @@ -482,14 +433,11 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 10 unmodified lines | ||
| 482 | 433 | Effect.gen(function* () { |
| 483 | 434 | const trimmed = url.trim(); |
| 484 | 435 | 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); | |
| 488 | 437 | if (parsed._tag === "None") return null; |
| 489 | 438 | |
| 490 | 439 | 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"); | |
| 493 | 441 | if (!isGoogleUrl && !isDiscoveryPath) return null; |
| 494 | 442 | |
| 495 | 443 | const discoveryText = yield* fetchDiscoveryDocument(trimmed).pipe( |
| @@ -497,9 +445,9 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 1 unmodified line | ||
| 497 | 445 | ); |
| 498 | 446 | if (!discoveryText) return null; |
| 499 | 447 | |
| 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 | + ); | |
| 503 | 451 | if (!manifest) return null; |
| 504 | 452 | |
| 505 | 453 | const name = Option.getOrElse( |
| @@ -529,10 +477,7 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 23 unmodified lines | ||
| 529 | 477 | existing.config.credentials, |
| 530 | 478 | typedCtx, |
| 531 | 479 | ); |
| 532 | − const text = yield* fetchDiscoveryDocument( | |
| 533 | − existing.config.discoveryUrl, | |
| 534 | − credentials, | |
| 535 | − ); | |
| 480 | + const text = yield* fetchDiscoveryDocument(existing.config.discoveryUrl, credentials); | |
| 536 | 481 | const manifest = yield* extractGoogleDiscoveryManifest(text); |
| 537 | 482 | const next = new GoogleDiscoveryStoredSourceDataSchema({ |
| 538 | 483 | ...existing.config, |
| @@ -542,11 +487,7 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 3 unmodified lines | ||
| 542 | 487 | servicePath: manifest.servicePath, |
| 543 | 488 | }); |
| 544 | 489 | 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))))), | |
| 550 | 491 | |
| 551 | 492 | // Connection refresh is owned by the canonical `"oauth2"` |
| 552 | 493 | // ConnectionProvider registered by core — no plugin-specific handler |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { Schema } from "effect"; |
| 2 | +import { SecretBackedValue } from "@executor/sdk"; | |
| 2 | 3 | |
| 3 | 4 | export const GoogleDiscoveryHttpMethod = Schema.Literal( |
| 4 | 5 | "get", |
| @@ -90,15 +91,8 @@ export const GoogleDiscoveryAuth = Schema.Union( · 85 unmodified lines | ||
| 90 | 91 | ); |
| 91 | 92 | export type GoogleDiscoveryAuth = typeof GoogleDiscoveryAuth.Type; |
| 92 | 93 | |
| 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; | |
| 102 | 96 | |
| 103 | 97 | export const GoogleDiscoveryFetchCredentials = Schema.Struct({ |
| 104 | 98 | headers: Schema.optional( |
| @@ -108,8 +102,7 @@ export const GoogleDiscoveryFetchCredentials = Schema.Struct({ · 3 unmodified lines | ||
| 108 | 102 | Schema.Record({ key: Schema.String, value: GoogleDiscoveryCredentialValue }), |
| 109 | 103 | ), |
| 110 | 104 | }); |
| 111 | −export type GoogleDiscoveryFetchCredentials = | |
| 112 | − typeof GoogleDiscoveryFetchCredentials.Type; | |
| 105 | +export type GoogleDiscoveryFetchCredentials = typeof GoogleDiscoveryFetchCredentials.Type; | |
| 113 | 106 | |
| 114 | 107 | export class GoogleDiscoveryStoredSourceData extends Schema.Class<GoogleDiscoveryStoredSourceData>( |
| 115 | 108 | "GoogleDiscoveryStoredSourceData", |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { Effect, Layer, Option } from "effect"; |
| 2 | 2 | import { HttpClient, HttpClientRequest } from "@effect/platform"; |
| 3 | +import { resolveSecretBackedMap } from "@executor/sdk"; | |
| 3 | 4 | |
| 4 | 5 | import { GraphqlInvocationError } from "./errors"; |
| 5 | 6 | import { type HeaderValue, type OperationBinding, InvocationResult } from "./types"; |
| @@ -17,33 +18,14 @@ export const resolveHeaders = ( · 11 unmodified lines | ||
| 17 | 18 | (acc, [, value]) => (typeof value === "string" ? acc : acc + 1), |
| 18 | 19 | 0, |
| 19 | 20 | ); |
| 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, | |
| 46 | 27 | }).pipe( |
| 28 | + Effect.map((resolved) => resolved ?? {}), | |
| 47 | 29 | Effect.withSpan("plugin.graphql.secret.resolve", { |
| 48 | 30 | attributes: { |
| 49 | 31 | "plugin.graphql.headers.total": entries.length, |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { Schema } from "effect"; |
| 2 | +import { SecretBackedValue } from "@executor/sdk"; | |
| 2 | 3 | |
| 3 | 4 | // --------------------------------------------------------------------------- |
| 4 | 5 | // GraphQL operation kind |
| @@ -11,18 +12,14 @@ export type GraphqlOperationKind = typeof GraphqlOperationKind.Type; · 6 unmodified lines | ||
| 11 | 12 | // Extracted field (becomes a tool) |
| 12 | 13 | // --------------------------------------------------------------------------- |
| 13 | 14 | |
| 14 | −export class GraphqlArgument extends Schema.Class<GraphqlArgument>( | |
| 15 | − "GraphqlArgument", | |
| 16 | −)({ | |
| 15 | +export class GraphqlArgument extends Schema.Class<GraphqlArgument>("GraphqlArgument")({ | |
| 17 | 16 | name: Schema.String, |
| 18 | 17 | typeName: Schema.String, |
| 19 | 18 | required: Schema.Boolean, |
| 20 | 19 | description: Schema.optionalWith(Schema.String, { as: "Option" }), |
| 21 | 20 | }) {} |
| 22 | 21 | |
| 23 | −export class ExtractedField extends Schema.Class<ExtractedField>( | |
| 24 | − "ExtractedField", | |
| 25 | −)({ | |
| 22 | +export class ExtractedField extends Schema.Class<ExtractedField>("ExtractedField")({ | |
| 26 | 23 | /** e.g. "user", "createUser" */ |
| 27 | 24 | fieldName: Schema.String, |
| 28 | 25 | /** "query" or "mutation" */ |
| @@ -35,9 +32,7 @@ export class ExtractedField extends Schema.Class<ExtractedField>( · 6 unmodified lines | ||
| 35 | 32 | returnTypeName: Schema.String, |
| 36 | 33 | }) {} |
| 37 | 34 | |
| 38 | −export class ExtractionResult extends Schema.Class<ExtractionResult>( | |
| 39 | − "ExtractionResult", | |
| 40 | −)({ | |
| 35 | +export class ExtractionResult extends Schema.Class<ExtractionResult>("ExtractionResult")({ | |
| 41 | 36 | /** Schema name from introspection */ |
| 42 | 37 | schemaName: Schema.optionalWith(Schema.String, { as: "Option" }), |
| 43 | 38 | fields: Schema.Array(ExtractedField), |
| @@ -47,9 +42,7 @@ export class ExtractionResult extends Schema.Class<ExtractionResult>( · 3 unmodified lines | ||
| 47 | 42 | // Operation binding — minimal data needed to invoke |
| 48 | 43 | // --------------------------------------------------------------------------- |
| 49 | 44 | |
| 50 | −export class OperationBinding extends Schema.Class<OperationBinding>( | |
| 51 | − "OperationBinding", | |
| 52 | −)({ | |
| 45 | +export class OperationBinding extends Schema.Class<OperationBinding>("OperationBinding")({ | |
| 53 | 46 | kind: GraphqlOperationKind, |
| 54 | 47 | fieldName: Schema.String, |
| 55 | 48 | /** The full GraphQL query/mutation string */ |
| @@ -62,13 +55,7 @@ export class OperationBinding extends Schema.Class<OperationBinding>( · 6 unmodified lines | ||
| 62 | 55 | // Invocation |
| 63 | 56 | // --------------------------------------------------------------------------- |
| 64 | 57 | |
| 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; | |
| 72 | 59 | export type HeaderValue = typeof HeaderValue.Type; |
| 73 | 60 | export const QueryParamValue = HeaderValue; |
| 74 | 61 | export type QueryParamValue = typeof QueryParamValue.Type; |
| @@ -86,30 +73,20 @@ export const GraphqlSourceAuth = Schema.Union( · 11 unmodified lines | ||
| 86 | 73 | ); |
| 87 | 74 | export type GraphqlSourceAuth = typeof GraphqlSourceAuth.Type; |
| 88 | 75 | |
| 89 | −export class InvocationConfig extends Schema.Class<InvocationConfig>( | |
| 90 | − "InvocationConfig", | |
| 91 | −)({ | |
| 76 | +export class InvocationConfig extends Schema.Class<InvocationConfig>("InvocationConfig")({ | |
| 92 | 77 | /** The GraphQL endpoint URL */ |
| 93 | 78 | endpoint: Schema.String, |
| 94 | 79 | /** 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 | + }), | |
| 101 | 83 | /** 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 | + }), | |
| 108 | 87 | }) {} |
| 109 | 88 | |
| 110 | −export class InvocationResult extends Schema.Class<InvocationResult>( | |
| 111 | − "InvocationResult", | |
| 112 | −)({ | |
| 89 | +export class InvocationResult extends Schema.Class<InvocationResult>("InvocationResult")({ | |
| 113 | 90 | status: Schema.Number, |
| 114 | 91 | data: Schema.NullOr(Schema.Unknown), |
| 115 | 92 | errors: Schema.NullOr(Schema.Unknown), |
| @@ -1,12 +1,9 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | −import { ScopeId } from "@executor/sdk"; | |
| 3 | +import { ScopeId, SecretBackedMap } from "@executor/sdk"; | |
| 4 | 4 | import { InternalError } from "@executor/api"; |
| 5 | 5 | |
| 6 | −import { | |
| 7 | − McpConnectionError, | |
| 8 | − McpToolDiscoveryError, | |
| 9 | −} from "../sdk/errors"; | |
| 6 | +import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors"; | |
| 10 | 7 | import { McpStoredSourceSchema } from "../sdk/stored-source"; |
| 11 | 8 | |
| 12 | 9 | // Re-export for handler use |
| @@ -43,15 +40,6 @@ const AuthPayload = Schema.Union( · 30 unmodified lines | ||
| 43 | 40 | ); |
| 44 | 41 | |
| 45 | 42 | 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 | − | |
| 55 | 43 | // --------------------------------------------------------------------------- |
| 56 | 44 | // Add source — discriminated union on transport |
| 57 | 45 | // --------------------------------------------------------------------------- |
| @@ -169,8 +157,11 @@ export class McpGroup extends HttpApiGroup.make("mcp") · 111 unmodified lines | ||
| 169 | 157 | .addSuccess(RefreshSourceResponse), |
| 170 | 158 | ) |
| 171 | 159 | .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 | + ), | |
| 174 | 165 | ) |
| 175 | 166 | .add( |
| 176 | 167 | HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}` |
| @@ -5,6 +5,7 @@ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth. · 4 unmodified lines | ||
| 5 | 5 | import { |
| 6 | 6 | SourceDetectionResult, |
| 7 | 7 | definePlugin, |
| 8 | + resolveSecretBackedMap as resolveSharedSecretBackedMap, | |
| 8 | 9 | type PluginCtx, |
| 9 | 10 | type StorageFailure, |
| 10 | 11 | } from "@executor/sdk"; |
| @@ -15,21 +16,11 @@ import { · 4 unmodified lines | ||
| 15 | 16 | type McpBindingStore, |
| 16 | 17 | type McpStoredSource, |
| 17 | 18 | } 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"; | |
| 23 | 20 | import { discoverTools } from "./discover"; |
| 24 | −import { | |
| 25 | − McpConnectionError, | |
| 26 | − McpToolDiscoveryError, | |
| 27 | −} from "./errors"; | |
| 21 | +import { McpConnectionError, McpToolDiscoveryError } from "./errors"; | |
| 28 | 22 | import { invokeMcpTool } from "./invoke"; |
| 29 | −import { | |
| 30 | − deriveMcpNamespace, | |
| 31 | − type McpToolManifestEntry, | |
| 32 | −} from "./manifest"; | |
| 23 | +import { deriveMcpNamespace, type McpToolManifestEntry } from "./manifest"; | |
| 33 | 24 | import { probeMcpEndpointShape } from "./probe-shape"; |
| 34 | 25 | import { |
| 35 | 26 | McpToolBinding, |
| @@ -200,38 +191,26 @@ const remoteConnectionError = (message: string) => · 164 unmodified lines | ||
| 200 | 191 | const mcpDiscoveryError = (message: string) => |
| 201 | 192 | new McpToolDiscoveryError({ stage: "list_tools", message }); |
| 202 | 193 | |
| 203 | −const resolveSecretBackedValue = ( | |
| 204 | − value: SecretBackedValue, | |
| 194 | +const resolveSecretBackedMap = ( | |
| 195 | + values: Record<string, SecretBackedValue> | undefined, | |
| 205 | 196 | 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) => | |
| 210 | 204 | "_tag" in err && err._tag === "SecretOwnedByConnectionError" |
| 211 | 205 | ? remoteConnectionError(`Failed to resolve secret "${value.secretId}"`) |
| 212 | 206 | : 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, | |
| 232 | 212 | ), |
| 233 | 213 | ); |
| 234 | −}; | |
| 235 | 214 | |
| 236 | 215 | const plainStringMap = ( |
| 237 | 216 | values: Record<string, SecretBackedValue> | undefined, |
| @@ -279,13 +258,15 @@ const resolveConnectorInput = ( · 41 unmodified lines | ||
| 279 | 258 | |
| 280 | 259 | const auth = sd.auth; |
| 281 | 260 | 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 | + ); | |
| 289 | 270 | if (val === null) { |
| 290 | 271 | return yield* Effect.fail( |
| 291 | 272 | remoteConnectionError(`Failed to resolve secret "${auth.secretId}"`), |
| @@ -304,9 +285,7 @@ const resolveConnectorInput = ( · 12 unmodified lines | ||
| 304 | 285 | Effect.mapError((err) => |
| 305 | 286 | remoteConnectionError( |
| 306 | 287 | `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) | |
| 310 | 289 | }`, |
| 311 | 290 | ), |
| 312 | 291 | ), |
| @@ -332,25 +311,15 @@ const resolveConnectorInput = ( · 19 unmodified lines | ||
| 332 | 311 | // --------------------------------------------------------------------------- |
| 333 | 312 | |
| 334 | 313 | 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>>; | |
| 344 | 316 | readonly cacheScope: Scope.CloseableScope; |
| 345 | 317 | } |
| 346 | 318 | |
| 347 | 319 | const makeRuntime = (): Effect.Effect<McpRuntime, never> => |
| 348 | 320 | Effect.gen(function* () { |
| 349 | 321 | 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>>(); | |
| 354 | 323 | const connectionCache = yield* ScopedCache.make({ |
| 355 | 324 | lookup: (key: string) => |
| 356 | 325 | Effect.acquireRelease( |
| @@ -366,8 +335,7 @@ const makeRuntime = (): Effect.Effect<McpRuntime, never> => · 9 unmodified lines | ||
| 366 | 335 | } |
| 367 | 336 | return connector; |
| 368 | 337 | }), |
| 369 | − (connection) => | |
| 370 | − Effect.promise(() => connection.close().catch(() => {})), | |
| 338 | + (connection) => Effect.promise(() => connection.close().catch(() => {})), | |
| 371 | 339 | ), |
| 372 | 340 | capacity: 64, |
| 373 | 341 | timeToLive: Duration.minutes(5), |
| @@ -444,540 +412,484 @@ const toMcpConfigEntry = ( · 70 unmodified lines | ||
| 444 | 412 | return entry; |
| 445 | 413 | }; |
| 446 | 414 | |
| 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 | + ); | |
| 465 | 432 | |
| 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), | |
| 481 | 437 | |
| 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 | + } | |
| 502 | 446 | |
| 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 }); | |
| 510 | 451 | |
| 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); | |
| 521 | 460 | |
| 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 | + }); | |
| 541 | 467 | |
| 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 | + ); | |
| 553 | 473 | |
| 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 | + } | |
| 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 | 496 | return yield* Effect.fail( |
| 566 | 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( | |
| 571 | − Effect.withSpan("mcp.plugin.probe_endpoint", { | |
| 572 | − attributes: { "mcp.endpoint": typeof input === "string" ? input : input.endpoint }, | |
| 573 | − }), | |
| 574 | − ); | |
| 503 | + } | |
| 575 | 504 | |
| 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"), | |
| 598 | 515 | ); |
| 599 | 516 | |
| 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 | + } | |
| 603 | 527 | |
| 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 | + ); | |
| 681 | 536 | |
| 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; | |
| 687 | 538 | |
| 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", { | |
| 694 | 554 | attributes: { |
| 695 | − "mcp.source.transport": config.transport, | |
| 696 | − "mcp.source.name": config.name, | |
| 555 | + "mcp.source.namespace": namespace, | |
| 556 | + "mcp.source.transport": sd.transport, | |
| 697 | 557 | }, |
| 698 | 558 | }), |
| 699 | 559 | ); |
| 700 | 560 | |
| 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 | + } | |
| 722 | 564 | |
| 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 | + }); | |
| 728 | 629 | }), |
| 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", { | |
| 740 | 633 | attributes: { |
| 741 | 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); | |
| 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 | + } | |
| 806 | 645 | |
| 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 | + ); | |
| 838 | 658 | |
| 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 | + ); | |
| 845 | 680 | |
| 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) => | |
| 857 | 682 | 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 }, | |
| 869 | 686 | }), |
| 870 | 687 | ); |
| 871 | − if (!entry) { | |
| 688 | + if (!sd) { | |
| 872 | 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( | |
| 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 | + }, | |
| 880 | 700 | }), |
| 881 | 701 | ); |
| 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 | + }), | |
| 887 | 751 | ); |
| 888 | − } | |
| 889 | 752 | |
| 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 }; | |
| 919 | 754 | }).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 }, | |
| 925 | 757 | }), |
| 926 | − ), | |
| 758 | + ); | |
| 927 | 759 | |
| 928 | − detect: ({ ctx, url }) => | |
| 760 | + const updateSource = (namespace: string, scope: string, input: McpUpdateSourceInput) => | |
| 929 | 761 | 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 | + } | |
| 932 | 821 | |
| 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}"`), | |
| 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"; | |
| 939 | − const namespace = deriveMcpNamespace({ endpoint: trimmed }); | |
| 870 | + detect: ({ ctx, url }) => | |
| 871 | + Effect.gen(function* () { | |
| 872 | + const trimmed = url.trim(); | |
| 873 | + if (!trimmed) return null; | |
| 940 | 874 | |
| 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; | |
| 945 | 877 | |
| 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 }); | |
| 951 | 880 | |
| 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 | + }); | |
| 961 | 885 | |
| 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 | + ); | |
| 980 | 891 | |
| 892 | + if (connected) { | |
| 981 | 893 | return new SourceDetectionResult({ |
| 982 | 894 | kind: "mcp", |
| 983 | 895 | confidence: "high", |
| @@ -985,49 +897,74 @@ export const mcpPlugin = definePlugin( · 1 unmodified line | ||
| 985 | 897 | name, |
| 986 | 898 | namespace, |
| 987 | 899 | }); |
| 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 }, | |
| 1010 | 930 | }), |
| 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 | +}); | |
| 1031 | 968 | |
| 1032 | 969 | // --------------------------------------------------------------------------- |
| 1033 | 970 | // McpPluginExtension — shape of `executor.mcp` for consumers that want |
| @@ -1045,10 +982,7 @@ export const mcpPlugin = definePlugin( · 11 unmodified lines | ||
| 1045 | 982 | * composition. `UniqueViolationError` passes through — plugins can |
| 1046 | 983 | * `Effect.catchTag` it if they want a friendlier user-facing error. |
| 1047 | 984 | */ |
| 1048 | −export type McpExtensionFailure = | |
| 1049 | − | McpConnectionError | |
| 1050 | − | McpToolDiscoveryError | |
| 1051 | − | StorageFailure; | |
| 985 | +export type McpExtensionFailure = McpConnectionError | McpToolDiscoveryError | StorageFailure; | |
| 1052 | 986 | |
| 1053 | 987 | export interface McpPluginExtension { |
| 1054 | 988 | readonly probeEndpoint: ( |
| @@ -1,4 +1,7 @@ | ||
| 1 | 1 | import { Schema } from "effect"; |
| 2 | +import { SecretBackedMap, SecretBackedValue } from "@executor/sdk"; | |
| 3 | + | |
| 4 | +export { SecretBackedMap, SecretBackedValue }; | |
| 2 | 5 | |
| 3 | 6 | // --------------------------------------------------------------------------- |
| 4 | 7 | // Remote transport type |
| @@ -46,18 +49,8 @@ export type McpConnectionAuth = typeof McpConnectionAuth.Type; · 41 unmodified lines | ||
| 46 | 49 | // Stored source data — discriminated union on transport |
| 47 | 50 | // --------------------------------------------------------------------------- |
| 48 | 51 | |
| 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 | − | |
| 58 | 52 | /** Common fields for remote string map schemas */ |
| 59 | 53 | const StringMap = Schema.Record({ key: Schema.String, value: Schema.String }); |
| 60 | −const SecretBackedMap = Schema.Record({ key: Schema.String, value: SecretBackedValue }); | |
| 61 | 54 | |
| 62 | 55 | export const McpRemoteSourceData = Schema.Struct({ |
| 63 | 56 | transport: Schema.Literal("remote"), |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | −import { ScopeId } from "@executor/sdk"; | |
| 3 | +import { ScopeId, SecretBackedValue } from "@executor/sdk"; | |
| 4 | 4 | import { InternalError } from "@executor/api"; |
| 5 | 5 | |
| 6 | 6 | import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors"; |
| @@ -21,14 +21,6 @@ const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId); · 14 unmodified lines | ||
| 21 | 21 | const namespaceParam = HttpApiSchema.param("namespace", Schema.String); |
| 22 | 22 | const sourceScopeIdParam = HttpApiSchema.param("sourceScopeId", ScopeId); |
| 23 | 23 | |
| 24 | −const SecretBackedValue = Schema.Union( | |
| 25 | − Schema.String, | |
| 26 | − Schema.Struct({ | |
| 27 | − secretId: Schema.String, | |
| 28 | − prefix: Schema.optional(Schema.String), | |
| 29 | − }), | |
| 30 | −); | |
| 31 | − | |
| 32 | 24 | const SpecFetchCredentialsPayload = Schema.Struct({ |
| 33 | 25 | headers: Schema.optional(Schema.Record({ key: Schema.String, value: SecretBackedValue })), |
| 34 | 26 | queryParams: Schema.optional(Schema.Record({ key: Schema.String, value: SecretBackedValue })), |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | SecretId, |
| 9 | 9 | SourceDetectionResult, |
| 10 | 10 | definePlugin, |
| 11 | + resolveSecretBackedMap, | |
| 11 | 12 | type PluginCtx, |
| 12 | 13 | type StorageFailure, |
| 13 | 14 | type ToolAnnotations, |
| @@ -471,39 +472,31 @@ const resolveConfiguredHeaders = ( · 457 unmodified lines | ||
| 471 | 472 | return resolved; |
| 472 | 473 | }); |
| 473 | 474 | |
| 474 | −const resolveHeaderValue = ( | |
| 475 | +const resolveHeaderValues = ( | |
| 475 | 476 | 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) => | |
| 482 | 487 | "_tag" in err && err._tag === "SecretOwnedByConnectionError" |
| 483 | 488 | ? new OpenApiOAuthError({ |
| 484 | 489 | message: `Secret not found for "${name}"`, |
| 485 | 490 | }) |
| 486 | 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) => | |
| 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 ?? {}), | |
| 497 | 499 | ); |
| 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))); | |
| 507 | 500 | |
| 508 | 501 | const resolveOAuthConnectionId = ( |
| 509 | 502 | ctx: PluginCtx<OpenapiStore>, |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { Schema } from "effect"; |
| 2 | −import { ConnectionId, ScopeId, SecretId } from "@executor/sdk"; | |
| 2 | +import { ConnectionId, ScopeId, SecretBackedValue, SecretId } from "@executor/sdk"; | |
| 3 | 3 | |
| 4 | 4 | // --------------------------------------------------------------------------- |
| 5 | 5 | // Branded IDs |
| @@ -61,10 +61,9 @@ export class EncodingObject extends Schema.Class<EncodingObject>("EncodingObject · 55 unmodified lines | ||
| 61 | 61 | export class MediaBinding extends Schema.Class<MediaBinding>("MediaBinding")({ |
| 62 | 62 | contentType: Schema.String, |
| 63 | 63 | 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 | + }), | |
| 68 | 67 | }) {} |
| 69 | 68 | |
| 70 | 69 | export class OperationRequestBody extends Schema.Class<OperationRequestBody>( |
| @@ -137,13 +136,7 @@ export class OperationBinding extends Schema.Class<OperationBinding>("OperationB · 66 unmodified lines | ||
| 137 | 136 | * A header value — either a static string or a reference to a secret. |
| 138 | 137 | * Stored as JSON-serializable data. |
| 139 | 138 | */ |
| 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; | |
| 147 | 140 | export type HeaderValue = typeof HeaderValue.Type; |
| 148 | 141 | |
| 149 | 142 | export class ConfiguredHeaderBinding extends Schema.Class<ConfiguredHeaderBinding>( |
| @@ -154,10 +147,7 @@ export class ConfiguredHeaderBinding extends Schema.Class<ConfiguredHeaderBindin · 4 unmodified lines | ||
| 154 | 147 | prefix: Schema.optional(Schema.String), |
| 155 | 148 | }) {} |
| 156 | 149 | |
| 157 | −export const ConfiguredHeaderValue = Schema.Union( | |
| 158 | − Schema.String, | |
| 159 | − ConfiguredHeaderBinding, | |
| 160 | −); | |
| 150 | +export const ConfiguredHeaderValue = Schema.Union(Schema.String, ConfiguredHeaderBinding); | |
| 161 | 151 | export type ConfiguredHeaderValue = typeof ConfiguredHeaderValue.Type; |
| 162 | 152 | |
| 163 | 153 | export const OpenApiSourceBindingValue = Schema.Union( |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { useId } from "react"; |
| 2 | 2 | import { PlusIcon } from "lucide-react"; |
| 3 | −import type { ScopeId } from "@executor/sdk"; | |
| 3 | +import type { ScopeId, SecretBackedValue } from "@executor/sdk"; | |
| 4 | 4 | |
| 5 | 5 | import { Button } from "../components/button"; |
| 6 | 6 | import { CardStack, CardStackContent, CardStackEntry } from "../components/card-stack"; |
| @@ -15,12 +15,7 @@ import { · 8 unmodified lines | ||
| 15 | 15 | } from "./secret-header-auth"; |
| 16 | 16 | import type { SecretPickerSecret } from "./secret-picker"; |
| 17 | 17 | |
| 18 | −export type SecretBackedValue = | |
| 19 | − | string | |
| 20 | − | { | |
| 21 | − readonly secretId: string; | |
| 22 | − readonly prefix?: string; | |
| 23 | − }; | |
| 18 | +export type { SecretBackedValue }; | |
| 24 | 19 | |
| 25 | 20 | export type QueryParamState = { |
| 26 | 21 | name: string; |
| @@ -5,14 +5,11 @@ import { cancelOAuth, startOAuth } from "../api/atoms"; · 4 unmodified lines | ||
| 5 | 5 | import { openOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup"; |
| 6 | 6 | import { useScope } from "../api/scope-context"; |
| 7 | 7 | 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"; | |
| 16 | 13 | |
| 17 | 14 | export type OAuthCompletionPayload = { |
| 18 | 15 | readonly connectionId: string; |