import { Effect, Match, Option, Schema } from "effect"; import type { Layer, Redacted } from "effect"; import { HttpClient } from "effect/unstable/http"; import { authToolFailure, detectInsufficientScope, AuthTemplateSlug, definePlugin, IntegrationAlreadyExistsError, IntegrationDetectionResult, IntegrationSlug, mergeAuthTemplates, sha256Hex, ToolName, ToolResult, type AuthMethodDescriptor, type IntegrationConfig, type IntegrationRecord, type PluginCtx, type StorageFailure, type ToolAnnotations, type ToolDef, } from "@executor-js/sdk/core"; import { TOKEN_VARIABLE, describeApiKeyAuthMethod, describeNoneAuthMethod, oauthBearerPlacement, renderAuthPlacements, requiredPlacementVariables, type RenderedAuthPlacements, } from "@executor-js/sdk/http-auth"; import { introspect, parseIntrospectionJson, type IntrospectionResult, type IntrospectionType, type IntrospectionField, type IntrospectionInputValue, type IntrospectionTypeRef, } from "./introspect"; import { extract } from "./extract"; import { GraphqlAuthRequiredError, GraphqlIntrospectionError, GraphqlInvocationError, } from "./errors"; import { effectiveOperationString, invokeWithLayer } from "./invoke"; import { validateOperationString } from "./validate-selection"; import { graphqlPresets } from "./presets"; import { makeDefaultGraphqlStore, type GraphqlStore, type StoredOperation } from "./store"; import { GraphqlAuthMethodInput, decodeGraphqlIntegrationConfig, decodeGraphqlIntegrationConfigOption, ExtractedField, GraphqlIntegrationConfig, expandGraphqlAuthMethodInputs, normalizeGraphqlAuthMethods, OperationBinding, type GraphqlAuthMethod, type GraphqlOperationKind, } from "./types"; // --------------------------------------------------------------------------- // GraphQL error-body decoding (for invocation responses) // --------------------------------------------------------------------------- const GraphqlErrorBody = Schema.Struct({ message: Schema.String }); const GraphqlErrorsBody = Schema.Array(Schema.Unknown); const decodeGraphqlErrorBody = Schema.decodeUnknownOption(GraphqlErrorBody); const decodeGraphqlErrorsBody = Schema.decodeUnknownOption(GraphqlErrorsBody); const decodeGraphqlErrors = (errors: unknown): readonly unknown[] | undefined => Option.getOrUndefined(decodeGraphqlErrorsBody(errors)); const extractGraphqlErrorMessage = (errors: readonly unknown[]): string | undefined => errors .map((error) => Option.getOrUndefined(decodeGraphqlErrorBody(error))?.message) .find((message) => message !== undefined && message.length > 0); const GRAPHQL_PLUGIN_ID = "graphql"; // --------------------------------------------------------------------------- // Extension input shapes // --------------------------------------------------------------------------- /** Register a GraphQL integration in the catalog. `endpoint` is the GraphQL URL; * `slug` (defaulted from the endpoint) is the catalog id; `introspectionJson` * supplies the schema when the endpoint disables live introspection; `headers` * / `queryParams` are static and also applied to add-time introspection; * `authenticationTemplate` declares the auth methods a connection can apply * through. */ const GraphqlAddIntegrationInputSchema = Schema.Struct({ endpoint: Schema.String, slug: Schema.optional(Schema.String), name: Schema.optional(Schema.String), /** Agent-visible catalog description. Falls back to the introspected * schema's own description, then the display name. */ description: Schema.optional(Schema.String), introspectionJson: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), authenticationTemplate: Schema.optional(Schema.Array(GraphqlAuthMethodInput)), }); export type GraphqlAddIntegrationInput = typeof GraphqlAddIntegrationInputSchema.Type; const GraphqlConfigureInputSchema = Schema.Struct({ name: Schema.optional(Schema.String), endpoint: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), authenticationTemplate: Schema.optional(Schema.Array(GraphqlAuthMethodInput)), }); export type GraphqlConfigureInput = typeof GraphqlConfigureInputSchema.Type; /** Input for the custom-method-create flow (HTTP `POST /graphql/integrations/ * :slug/config`). Unlike `configure` (which REPLACES the whole config for the * generic repair path), `configureAuth` MERGE-APPENDS these methods onto the * integration's existing `authenticationTemplate`, mirroring OpenAPI's * `configure`. */ const GraphqlConfigureAuthInputSchema = Schema.Struct({ authenticationTemplate: Schema.Array(GraphqlAuthMethodInput), mode: Schema.optional(Schema.Literals(["merge", "replace"])), }); export type GraphqlConfigureAuthInput = typeof GraphqlConfigureAuthInputSchema.Type; // --------------------------------------------------------------------------- // Static control-tool schemas // --------------------------------------------------------------------------- const StaticAddIntegrationOutputSchema = Schema.Struct({ slug: Schema.String, name: Schema.String, }); const StaticGetIntegrationInputSchema = Schema.Struct({ slug: Schema.String, }); const StaticGetIntegrationOutputSchema = Schema.Struct({ integration: Schema.NullOr(Schema.Unknown), }); const StaticAddIntegrationInputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(GraphqlAddIntegrationInputSchema), ); const StaticAddIntegrationOutputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(StaticAddIntegrationOutputSchema), ); const StaticGetIntegrationInputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(StaticGetIntegrationInputSchema), ); const StaticGetIntegrationOutputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(StaticGetIntegrationOutputSchema), ); const graphqlToolFailure = (code: string, message: string, details?: unknown) => ToolResult.fail({ code, message, ...(details === undefined ? {} : { details }), }); const graphqlAuthToolFailure = (failure: GraphqlAuthRequiredError) => authToolFailure({ code: failure.code, message: failure.message, integration: { id: failure.integration, scope: failure.owner }, credential: { kind: failure.credentialKind, ...(failure.credentialLabel ? { label: failure.credentialLabel } : {}), connectionId: failure.connection, }, ...(failure.status !== undefined ? { status: failure.status } : {}), ...(failure.details !== undefined ? { upstream: { ...(failure.status !== undefined ? { status: failure.status } : {}), details: failure.details, }, } : {}), }); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Match `token` as a separator-bounded run inside a URL hostname or path, * used as a low-confidence detection hint when introspection fails. */ const urlMatchesToken = (url: URL, token: string): boolean => { const re = new RegExp(`(?:^|[^a-z0-9])${token}(?:$|[^a-z0-9])`, "i"); return re.test(url.hostname) || re.test(url.pathname); }; /** Derive an integration slug from an endpoint URL. */ const slugFromEndpoint = (endpoint: string): string => { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL construction throws; this helper intentionally falls back to the stable default slug try { const url = new URL(endpoint); return url.hostname.replace(/[^a-z0-9]+/gi, "_").toLowerCase(); } catch { return "graphql"; } }; const formatTypeRef = (ref: IntrospectionTypeRef): string => Match.value(ref.kind).pipe( Match.when("NON_NULL", () => (ref.ofType ? `${formatTypeRef(ref.ofType)}!` : "Unknown!")), Match.when("LIST", () => (ref.ofType ? `[${formatTypeRef(ref.ofType)}]` : "[Unknown]")), Match.option, Option.getOrElse(() => ref.name ?? "Unknown"), ); const unwrapTypeName = (ref: IntrospectionTypeRef): string => { if (ref.name) return ref.name; if (ref.ofType) return unwrapTypeName(ref.ofType); return "Unknown"; }; // Composite (output) types require a sub-selection; leaves (scalars/enums) must // not have one. Anything we cannot resolve in the type map is treated as a leaf // (custom scalars live in the map as SCALAR; truly-unknown types are rare). const isCompositeType = ( ref: IntrospectionTypeRef, types: ReadonlyMap, ): boolean => { const kind = types.get(unwrapTypeName(ref))?.kind; return kind === "OBJECT" || kind === "INTERFACE" || kind === "UNION"; }; // A field whose argument is non-null without a default cannot be selected by the // generator: it has no value to pass and emitting the field without the argument // is invalid (e.g. GitLab's `metadata.featureFlags(names:)`). Root-field required // arguments are different: those are threaded as operation variables (and // surfaced on the tool's input schema) in `buildOperationStringForField`. const hasRequiredArgWithoutDefault = (field: IntrospectionField): boolean => field.args.some( (arg: IntrospectionInputValue) => arg.type.kind === "NON_NULL" && arg.defaultValue == null, ); // Build the DEFAULT selection set for a field's return type: every scalar/enum // leaf the generator can select without arguments. It deliberately does NOT // recurse into composite fields or guess at nested selections, for two reasons: // - A real schema (GitLab has 4000+ types) makes any recursive auto-expansion // either arbitrary (which N fields? how deep?) or so large the server // rejects it for exceeding its query-complexity budget. // - A bounded-but-arbitrary selection silently freezes a partial view at sync // time. Instead, callers that want nested or list data pass an explicit // `select` (see buildOperationStringForField / invoke), so the choice of // deeper fields is the caller's, not a guess baked into the tool. // The result is always valid: selecting only leaves never needs a sub-selection, // and a composite type with no selectable leaves falls back to `__typename`. const buildDefaultSelectionSet = ( ref: IntrospectionTypeRef, types: ReadonlyMap, ): string => { const objectType = types.get(unwrapTypeName(ref)); if (!objectType?.fields) return ""; // scalar / enum / unknown: no selection if (objectType.kind === "SCALAR" || objectType.kind === "ENUM") return ""; const leaves = objectType.fields .filter( (f: IntrospectionField) => !f.name.startsWith("__") && !hasRequiredArgWithoutDefault(f) && !isCompositeType(f.type, types), ) .map((f: IntrospectionField) => f.name); // A composite type MUST have a non-empty selection; `__typename` is a leaf // that exists on every composite, so it is a safe minimal fallback. return leaves.length > 0 ? `{ ${leaves.join(" ")} }` : "{ __typename }"; }; // Name every generated operation: some servers reject anonymous operations, and // APM tooling keys traces off the operation name. Field names are already valid // GraphQL name tokens, so the upper-cased field name is a safe operation name. const operationNameForField = (fieldName: string): string => fieldName.charAt(0).toUpperCase() + fieldName.slice(1); interface BuiltOperation { /** The operation with the default (scalar-leaf) selection. */ readonly operationString: string; /** Everything up to (not including) the field's selection set: * `query Op($a: T) { field(a: $a)`. A caller-supplied `select` is wrapped as * `{