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