import { applyCookiePlacementsToHeaders, applyHttpQueryPlacementsToUrl, applyJsonBodyPlacements, } from "@executor/codemode-core"; import { buildGraphqlToolPresentation, compileGraphqlToolDefinitions, extractGraphqlManifest, GRAPHQL_INTROSPECTION_QUERY, type GraphqlToolProviderData, } from "../graphql-tools"; import type { Source } from "#schema"; import { StringMapSchema } from "#schema"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { createCatalogImportMetadata, createGraphqlCatalogFragment, type GraphqlCatalogOperationInput, } from "../source-catalog-snapshot"; import { createSourceCatalogSyncResult } from "../source-catalog-support"; import type { SourceAdapter } from "./types"; import { ConnectHttpAuthSchema, ConnectHttpImportAuthSchema, decodeBindingConfig, decodeSourceBindingPayload, emptySourceBindingState, encodeBindingConfig, isSourceCredentialRequiredError, OptionalNullableStringSchema, SourceCredentialRequiredError, SourceConnectCommonFieldsSchema, } from "./shared"; const GraphqlConnectPayloadSchema = Schema.extend( SourceConnectCommonFieldsSchema, Schema.extend( ConnectHttpImportAuthSchema, Schema.Struct({ kind: Schema.Literal("graphql"), auth: Schema.optional(ConnectHttpAuthSchema), }), ), ); const GraphqlExecutorAddInputSchema = Schema.extend( ConnectHttpImportAuthSchema, Schema.Struct({ kind: Schema.Literal("graphql"), endpoint: Schema.String, name: OptionalNullableStringSchema, namespace: OptionalNullableStringSchema, auth: Schema.optional(ConnectHttpAuthSchema), }), ); const GraphqlBindingConfigSchema = Schema.Struct({ defaultHeaders: Schema.NullOr(StringMapSchema), }); type GraphqlBindingConfig = typeof GraphqlBindingConfigSchema.Type; const GraphqlSourceBindingPayloadSchema = Schema.Struct({ defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)), }); const GRAPHQL_BINDING_CONFIG_VERSION = 1; const bindingHasAnyField = ( value: unknown, fields: readonly string[], ): boolean => value !== null && typeof value === "object" && !Array.isArray(value) && fields.some((field) => Object.prototype.hasOwnProperty.call(value, field)); const graphqlBindingConfigFromSource = ( source: Pick, ): Effect.Effect => Effect.gen(function* () { if (bindingHasAnyField(source.binding, ["transport", "queryParams", "headers"])) { return yield* Effect.fail( new Error("GraphQL sources cannot define MCP transport settings"), ); } if (bindingHasAnyField(source.binding, ["specUrl"])) { return yield* Effect.fail(new Error("GraphQL sources cannot define specUrl")); } const bindingConfig = yield* decodeSourceBindingPayload({ sourceId: source.id, label: "GraphQL", version: source.bindingVersion, expectedVersion: GRAPHQL_BINDING_CONFIG_VERSION, schema: GraphqlSourceBindingPayloadSchema, value: source.binding, allowedKeys: ["defaultHeaders"], }); return { defaultHeaders: bindingConfig.defaultHeaders ?? null, } satisfies GraphqlBindingConfig; }); const fetchGraphqlIntrospectionDocumentWithHeaders = (input: { url: string; headers?: Readonly>; queryParams?: Readonly>; cookies?: Readonly>; bodyValues?: Readonly>; }): Effect.Effect => Effect.tryPromise({ try: async () => { const response = await fetch( applyHttpQueryPlacementsToUrl({ url: input.url, queryParams: input.queryParams, }).toString(), { method: "POST", headers: applyCookiePlacementsToHeaders({ headers: { "content-type": "application/json", ...(input.headers ?? {}), }, cookies: input.cookies, }), body: JSON.stringify( applyJsonBodyPlacements({ body: { query: GRAPHQL_INTROSPECTION_QUERY, operationName: "IntrospectionQuery", }, bodyValues: input.bodyValues, label: `GraphQL introspection ${input.url}`, }), ), }, ); const text = await response.text(); let parsed: unknown; try { parsed = JSON.parse(text) as unknown; } catch (cause) { throw new Error( `GraphQL introspection endpoint did not return JSON: ${cause instanceof Error ? cause.message : String(cause)}`, ); } if (!response.ok) { if (response.status === 401 || response.status === 403) { throw new SourceCredentialRequiredError( "import", `GraphQL introspection requires credentials (status ${response.status})`, ); } throw new Error( `GraphQL introspection failed with status ${response.status}`, ); } return JSON.stringify(parsed, null, 2); }, catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)), }); const graphqlCatalogOperationFromDefinition = (input: { definition: ReturnType[number]; manifest: Parameters[0]["manifest"]; }): GraphqlCatalogOperationInput => { const presentation = buildGraphqlToolPresentation({ manifest: input.manifest, definition: input.definition, }); return { toolId: input.definition.toolId, title: input.definition.name, description: input.definition.description, effect: input.definition.operationType === "query" ? "read" : "write", inputSchema: presentation.inputSchema, outputSchema: presentation.outputSchema, providerData: presentation.providerData as GraphqlToolProviderData, }; }; export const graphqlSourceAdapter: SourceAdapter = { key: "graphql", displayName: "GraphQL", family: "http_api", bindingConfigVersion: GRAPHQL_BINDING_CONFIG_VERSION, providerKey: "generic_graphql", defaultImportAuthPolicy: "reuse_runtime", connectPayloadSchema: GraphqlConnectPayloadSchema, executorAddInputSchema: GraphqlExecutorAddInputSchema, executorAddHelpText: [ "endpoint is the GraphQL HTTP endpoint.", ], executorAddInputSignatureWidth: 320, serializeBindingConfig: (source) => encodeBindingConfig({ adapterKey: "graphql", version: GRAPHQL_BINDING_CONFIG_VERSION, payloadSchema: GraphqlBindingConfigSchema, payload: Effect.runSync(graphqlBindingConfigFromSource(source)), }), deserializeBindingConfig: ({ id, bindingConfigJson }) => Effect.map( decodeBindingConfig({ sourceId: id, label: "GraphQL", adapterKey: "graphql", version: GRAPHQL_BINDING_CONFIG_VERSION, payloadSchema: GraphqlBindingConfigSchema, value: bindingConfigJson, }), ({ version, payload }) => ({ version, payload, }), ), bindingStateFromSource: (source) => Effect.map(graphqlBindingConfigFromSource(source), (bindingConfig) => ({ ...emptySourceBindingState, defaultHeaders: bindingConfig.defaultHeaders, }), ), sourceConfigFromSource: (source) => Effect.runSync( Effect.map(graphqlBindingConfigFromSource(source), (bindingConfig) => ({ kind: "graphql", endpoint: source.endpoint, defaultHeaders: bindingConfig.defaultHeaders, })), ), validateSource: (source) => Effect.gen(function* () { const bindingConfig = yield* graphqlBindingConfigFromSource(source); return { ...source, bindingVersion: GRAPHQL_BINDING_CONFIG_VERSION, binding: { defaultHeaders: bindingConfig.defaultHeaders, }, }; }), shouldAutoProbe: (source) => source.enabled && (source.status === "draft" || source.status === "probing"), syncCatalog: ({ source, resolveAuthMaterialForSlot }) => Effect.gen(function* () { const bindingConfig = yield* graphqlBindingConfigFromSource(source); const auth = yield* resolveAuthMaterialForSlot("import"); const graphqlDocument = yield* fetchGraphqlIntrospectionDocumentWithHeaders( { url: source.endpoint, headers: { ...(bindingConfig.defaultHeaders ?? {}), ...auth.headers, }, queryParams: auth.queryParams, cookies: auth.cookies, bodyValues: auth.bodyValues, }, ).pipe( Effect.withSpan("graphql.introspection.fetch", { kind: "client", attributes: { "executor.source.id": source.id, "executor.source.endpoint": source.endpoint, }, }), Effect.mapError( (cause) => isSourceCredentialRequiredError(cause) ? cause : new Error( `Failed fetching GraphQL introspection for ${source.id}: ${cause.message}`, ), ), ); const manifest = yield* extractGraphqlManifest( source.name, graphqlDocument, ).pipe( Effect.withSpan("graphql.manifest.extract", { attributes: { "executor.source.id": source.id, }, }), Effect.mapError((cause) => cause instanceof Error ? cause : new Error(String(cause)), ), ); yield* Effect.annotateCurrentSpan("graphql.tool.count", manifest.tools.length); const definitions = yield* Effect.sync(() => compileGraphqlToolDefinitions(manifest)).pipe( Effect.withSpan("graphql.definitions.compile", { attributes: { "executor.source.id": source.id, "graphql.tool.count": manifest.tools.length, }, }), ); yield* Effect.annotateCurrentSpan("graphql.definition.count", definitions.length); const operations = yield* Effect.sync(() => definitions.map((definition) => graphqlCatalogOperationFromDefinition({ definition, manifest, }) ) ).pipe( Effect.withSpan("graphql.operations.build", { attributes: { "executor.source.id": source.id, "graphql.definition.count": definitions.length, }, }), ); const now = Date.now(); const fragment = yield* Effect.sync(() => createGraphqlCatalogFragment({ source, documents: [{ documentKind: "graphql_introspection", documentKey: source.endpoint, contentText: graphqlDocument, fetchedAt: now, }], operations, }) ).pipe( Effect.withSpan("graphql.snapshot.build", { attributes: { "executor.source.id": source.id, "graphql.operation.count": operations.length, }, }), ); return createSourceCatalogSyncResult({ fragment, importMetadata: createCatalogImportMetadata({ source, adapterKey: "graphql", }), sourceHash: manifest.sourceHash, }); }).pipe( Effect.withSpan("graphql.syncCatalog", { attributes: { "executor.source.id": source.id, "executor.source.endpoint": source.endpoint, }, }), ), };