import { Effect, Option } from "effect";import type { Layer } from "effect";import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { GraphqlGroup } from "../api/group";import { GraphqlExtensionService, GraphqlHandlers } from "../api/handlers"; import { definePlugin, ScopeId, SourceDetectionResult, Usage, type PluginCtx, type ToolAnnotations, type ToolRow,} from "@executor-js/sdk/core"; import { headersToConfigValues, type ConfigFileSink, type GraphqlSourceConfig as GraphqlConfigEntry,} from "@executor-js/config"; import { introspect, parseIntrospectionJson, type IntrospectionResult, type IntrospectionType, type IntrospectionField, type IntrospectionTypeRef,} from "./introspect";import { extract } from "./extract";import { GraphqlIntrospectionError, GraphqlInvocationError } from "./errors";import { invokeWithLayer, resolveHeaders } from "./invoke";import { graphqlSchema, makeDefaultGraphqlStore, type GraphqlStore, type StoredGraphqlSource, type StoredOperation,} from "./store";import { ExtractedField, type GraphqlSourceAuth, OperationBinding, type HeaderValue as HeaderValueValue, type QueryParamValue, type GraphqlOperationKind,} from "./types"; // ---------------------------------------------------------------------------// Plugin config// --------------------------------------------------------------------------- export type HeaderValue = HeaderValueValue; export interface GraphqlSourceConfig { /** The GraphQL endpoint URL */ readonly endpoint: string; /** * Executor scope id that owns this source row. Must be one of the * executor's configured scopes. Typical shape: an admin adds the * source at the outermost (organization) scope so it's visible to * every inner (per-user) scope via fall-through reads. */ readonly scope: string; /** Display name for the source. Falls back to namespace if not provided. */ readonly name?: string; /** Optional: introspection JSON text (if endpoint doesn't support introspection) */ readonly introspectionJson?: string; /** Namespace for the tools (derived from endpoint if not provided) */ readonly namespace?: string; /** Headers applied to every request. Values can reference secrets. */ readonly headers?: Record<string, HeaderValue>; /** Query parameters applied to every request. Values can reference secrets. */ readonly queryParams?: Record<string, QueryParamValue>; /** Optional OAuth2 connection used as a Bearer token for every request. */ readonly auth?: GraphqlSourceAuth;} // ---------------------------------------------------------------------------// Plugin extension// --------------------------------------------------------------------------- export interface GraphqlUpdateSourceInput { readonly name?: string; readonly endpoint?: string; readonly headers?: Record<string, HeaderValue>; readonly queryParams?: Record<string, QueryParamValue>; readonly auth?: GraphqlSourceAuth;} // ---------------------------------------------------------------------------// Helpers// --------------------------------------------------------------------------- /** Derive a namespace from an endpoint URL */const namespaceFromEndpoint = (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 namespace try { const url = new URL(endpoint); return url.hostname.replace(/[^a-z0-9]+/gi, "_").toLowerCase(); } catch { return "graphql"; }}; const formatTypeRef = (ref: IntrospectionTypeRef): string => { switch (ref.kind) { case "NON_NULL": return ref.ofType ? `${formatTypeRef(ref.ofType)}!` : "Unknown!"; case "LIST": return ref.ofType ? `[${formatTypeRef(ref.ofType)}]` : "[Unknown]"; default: return ref.name ?? "Unknown"; }}; const unwrapTypeName = (ref: IntrospectionTypeRef): string => { if (ref.name) return ref.name; if (ref.ofType) return unwrapTypeName(ref.ofType); return "Unknown";}; const buildSelectionSet = ( ref: IntrospectionTypeRef, types: ReadonlyMap<string, IntrospectionType>, depth: number, seen: Set<string>,): string => { if (depth > 2) return ""; const leafName = unwrapTypeName(ref); if (seen.has(leafName)) return ""; const objectType = types.get(leafName); if (!objectType?.fields) return ""; const kind = objectType.kind; if (kind === "SCALAR" || kind === "ENUM") return ""; seen.add(leafName); const subFields = objectType.fields .filter((f) => !f.name.startsWith("__")) .slice(0, 12) .map((f) => { const sub = buildSelectionSet(f.type, types, depth + 1, seen); return sub ? `${f.name} ${sub}` : f.name; }); seen.delete(leafName); return subFields.length > 0 ? `{ ${subFields.join(" ")} }` : "";}; const buildOperationStringForField = ( kind: GraphqlOperationKind, field: IntrospectionField, types: ReadonlyMap<string, IntrospectionType>,): string => { const opType = kind === "query" ? "query" : "mutation"; const varDefs = field.args.map((arg) => { const typeName = formatTypeRef(arg.type); return `$${arg.name}: ${typeName}`; }); const argPasses = field.args.map((arg) => `${arg.name}: $${arg.name}`); const selectionSet = buildSelectionSet(field.type, types, 0, new Set()); const varDefsStr = varDefs.length > 0 ? `(${varDefs.join(", ")})` : ""; const argPassStr = argPasses.length > 0 ? `(${argPasses.join(", ")})` : ""; return `${opType}${varDefsStr} { ${field.name}${argPassStr}${selectionSet ? ` ${selectionSet}` : ""} }`;}; interface PreparedOperation { readonly toolPath: string; readonly description: string; readonly inputSchema: unknown; readonly binding: OperationBinding;} const prepareOperations = ( fields: readonly ExtractedField[], introspection: IntrospectionResult,): readonly PreparedOperation[] => { const typeMap = new Map<string, IntrospectionType>(); for (const t of introspection.__schema.types) { typeMap.set(t.name, t); } const fieldMap = new Map<string, { kind: GraphqlOperationKind; field: IntrospectionField }>(); const schema = introspection.__schema; for (const rootKind of ["query", "mutation"] as const) { const typeName = rootKind === "query" ? schema.queryType?.name : schema.mutationType?.name; if (!typeName) continue; const rootType = typeMap.get(typeName); if (!rootType?.fields) continue; for (const f of rootType.fields) { if (!f.name.startsWith("__")) { fieldMap.set(`${rootKind}.${f.name}`, { kind: rootKind, field: f }); } } } return fields.map((extracted) => { const prefix = extracted.kind === "mutation" ? "mutation" : "query"; const toolPath = `${prefix}.${extracted.fieldName}`; const description = Option.getOrElse( extracted.description, () => `GraphQL ${extracted.kind}: ${extracted.fieldName} -> ${extracted.returnTypeName}`, ); const key = `${extracted.kind}.${extracted.fieldName}`; const entry = fieldMap.get(key); const operationString = entry ? buildOperationStringForField(entry.kind, entry.field, typeMap) : `${extracted.kind} { ${extracted.fieldName} }`; const binding = new OperationBinding({ kind: extracted.kind, fieldName: extracted.fieldName, operationString, variableNames: extracted.arguments.map((a) => a.name), }); return { toolPath, description, inputSchema: Option.getOrUndefined(extracted.inputSchema), binding, }; });}; const annotationsFor = (binding: OperationBinding): ToolAnnotations => { if (binding.kind === "mutation") { return { requiresApproval: true, approvalDescription: `mutation ${binding.fieldName}`, }; } return {};}; // ---------------------------------------------------------------------------// Plugin factory// --------------------------------------------------------------------------- export interface GraphqlPluginOptions { readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; /** If provided, source add/remove is mirrored to executor.jsonc * (best-effort — file errors are logged, not raised). */ readonly configFile?: ConfigFileSink;} const toGraphqlConfigEntry = ( namespace: string, config: GraphqlSourceConfig,): GraphqlConfigEntry => ({ kind: "graphql", endpoint: config.endpoint, introspectionJson: config.introspectionJson, namespace, headers: headersToConfigValues(config.headers),}); const makeGraphqlExtension = ( ctx: PluginCtx<GraphqlStore>, httpClientLayer: Layer.Layer<HttpClient.HttpClient>, configFile: ConfigFileSink | undefined,) => { const resolveConfigValues = (values: Record<string, HeaderValue> | undefined) => Effect.gen(function* () { if (!values) return undefined; const resolved = yield* resolveHeaders(values, ctx.secrets); return Object.keys(resolved).length > 0 ? resolved : undefined; }); const resolveOAuthHeader = (auth: GraphqlSourceAuth | undefined) => Effect.gen(function* () { if (!auth || auth.kind === "none") return undefined; const accessToken = yield* ctx.connections.accessToken(auth.connectionId).pipe( Effect.mapError( () => new GraphqlIntrospectionError({ message: `Failed to resolve OAuth connection "${auth.connectionId}"`, }), ), ); return { Authorization: `Bearer ${accessToken}` }; }); const resolveRequestHeaders = ( headers: Record<string, HeaderValue> | undefined, auth: GraphqlSourceAuth | undefined, ) => Effect.gen(function* () { const resolvedHeaders = yield* resolveConfigValues(headers); const oauthHeader = yield* resolveOAuthHeader(auth); return { ...(resolvedHeaders ?? {}), ...(oauthHeader ?? {}) }; }); const addSourceInternal = (config: GraphqlSourceConfig) => ctx.transaction( Effect.gen(function* () { let introspectionResult: IntrospectionResult; if (config.introspectionJson) { introspectionResult = yield* parseIntrospectionJson(config.introspectionJson); } else { const resolvedHeaders = yield* resolveRequestHeaders(config.headers, config.auth); const resolvedQueryParams = yield* resolveConfigValues(config.queryParams); introspectionResult = yield* introspect( config.endpoint, Object.keys(resolvedHeaders).length > 0 ? resolvedHeaders : undefined, resolvedQueryParams, ).pipe(Effect.provide(httpClientLayer)); } const { result, definitions } = yield* extract(introspectionResult); const namespace = config.namespace ?? namespaceFromEndpoint(config.endpoint); const prepared = prepareOperations(result.fields, introspectionResult); const displayName = config.name?.trim() || namespace; const storedSource: StoredGraphqlSource = { namespace, scope: config.scope, name: displayName, endpoint: config.endpoint, headers: config.headers ?? {}, queryParams: config.queryParams ?? {}, auth: config.auth ?? { kind: "none" }, }; const storedOps: StoredOperation[] = prepared.map((p) => ({ toolId: `${namespace}.${p.toolPath}`, sourceId: namespace, binding: p.binding, })); yield* ctx.storage.upsertSource(storedSource, storedOps); yield* ctx.core.sources.register({ id: namespace, scope: config.scope, kind: "graphql", name: displayName, url: config.endpoint, canRemove: true, canRefresh: false, canEdit: true, tools: prepared.map((p) => ({ name: p.toolPath, description: p.description, inputSchema: p.inputSchema, })), }); if (Object.keys(definitions).length > 0) { yield* ctx.core.definitions.register({ sourceId: namespace, scope: config.scope, definitions, }); } return { toolCount: prepared.length, namespace }; }), ); return { addSource: (config: GraphqlSourceConfig) => addSourceInternal(config).pipe( Effect.tap((result) => configFile ? configFile.upsertSource(toGraphqlConfigEntry(result.namespace, config)) : Effect.void, ), ), removeSource: (namespace: string, scope: string) => Effect.gen(function* () { yield* ctx.transaction( Effect.gen(function* () { yield* ctx.storage.removeSource(namespace, scope); yield* ctx.core.sources.unregister(namespace); }), ); if (configFile) { yield* configFile.removeSource(namespace); } }), getSource: (namespace: string, scope: string) => ctx.storage.getSource(namespace, scope), updateSource: (namespace: string, scope: string, input: GraphqlUpdateSourceInput) => ctx.storage.updateSourceMeta(namespace, scope, { name: input.name?.trim() || undefined, endpoint: input.endpoint, headers: input.headers, queryParams: input.queryParams, auth: input.auth, }), };}; export type GraphqlPluginExtension = ReturnType<typeof makeGraphqlExtension>; export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer; return { id: "graphql" as const, packageName: "@executor-js/plugin-graphql", schema: graphqlSchema, storage: (deps): GraphqlStore => makeDefaultGraphqlStore(deps), extension: (ctx) => makeGraphqlExtension(ctx, httpClientLayer, options?.configFile), staticSources: (self) => [ { id: "graphql", kind: "control", name: "GraphQL", tools: [ { name: "addSource", description: "Add a GraphQL endpoint and register its operations as tools", inputSchema: { type: "object", properties: { endpoint: { type: "string" }, name: { type: "string" }, introspectionJson: { type: "string" }, namespace: { type: "string" }, headers: { type: "object" }, queryParams: { type: "object" }, auth: { type: "object" }, }, required: ["endpoint"], }, outputSchema: { type: "object", properties: { toolCount: { type: "number" }, }, required: ["toolCount"], }, // Static-tool callers don't name a scope. Default to the // outermost scope in the executor's stack — for a single- // scope executor that's the only scope; for a per-user // stack `[user, org]` it writes at `org` so the source is // visible across every user. handler: ({ ctx, args }) => self.addSource({ ...(args as Omit<GraphqlSourceConfig, "scope">), scope: ctx.scopes.at(-1)!.id, }), }, ], }, ], invokeTool: ({ ctx, toolRow, args }) => Effect.gen(function* () { // toolRow.scope_id is the resolved owning scope of the tool // (innermost-wins from the executor's stack). The matching // graphql_operation + graphql_source rows live at the same // scope, so pin every store lookup to it instead of relying // on the scoped adapter's stack-wide fall-through. const toolScope = toolRow.scope_id; const op = yield* ctx.storage.getOperationByToolId(toolRow.id, toolScope); if (!op) { return yield* new GraphqlInvocationError({ message: `No GraphQL operation found for tool "${toolRow.id}"`, statusCode: Option.none(), }); } const source = yield* ctx.storage.getSource(op.sourceId, toolScope); if (!source) { return yield* new GraphqlInvocationError({ message: `No GraphQL source found for "${op.sourceId}"`, statusCode: Option.none(), }); } const resolvedHeaders = yield* resolveHeaders(source.headers, ctx.secrets); const resolvedQueryParams = yield* resolveHeaders(source.queryParams, ctx.secrets); if (source.auth.kind === "oauth2") { const accessToken = yield* ctx.connections.accessToken(source.auth.connectionId); resolvedHeaders.Authorization = `Bearer ${accessToken}`; } const result = yield* invokeWithLayer( op.binding, (args ?? {}) as Record<string, unknown>, source.endpoint,Lines 1–500 of 648. Load more
import { Effect, Option } from "effect"; import type { Layer } from "effect"; import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { GraphqlGroup } from "../api/group"; import { GraphqlExtensionService, GraphqlHandlers } from "../api/handlers"; import { definePlugin, ScopeId, SourceDetectionResult, Usage, type PluginCtx, type ToolAnnotations, type ToolRow, } from "@executor-js/sdk/core"; import { headersToConfigValues, type ConfigFileSink, type GraphqlSourceConfig as GraphqlConfigEntry, } from "@executor-js/config"; import { introspect, parseIntrospectionJson, type IntrospectionResult, type IntrospectionType, type IntrospectionField, type IntrospectionTypeRef, } from "./introspect"; import { extract } from "./extract"; import { GraphqlIntrospectionError, GraphqlInvocationError } from "./errors"; import { invokeWithLayer, resolveHeaders } from "./invoke"; import { graphqlSchema, makeDefaultGraphqlStore, type GraphqlStore, type StoredGraphqlSource, type StoredOperation, } from "./store"; import { ExtractedField, type GraphqlSourceAuth, OperationBinding, type HeaderValue as HeaderValueValue, type QueryParamValue, type GraphqlOperationKind, } from "./types"; // --------------------------------------------------------------------------- // Plugin config // --------------------------------------------------------------------------- export type HeaderValue = HeaderValueValue; export interface GraphqlSourceConfig { /** The GraphQL endpoint URL */ readonly endpoint: string; /** * Executor scope id that owns this source row. Must be one of the * executor's configured scopes. Typical shape: an admin adds the * source at the outermost (organization) scope so it's visible to * every inner (per-user) scope via fall-through reads. */ readonly scope: string; /** Display name for the source. Falls back to namespace if not provided. */ readonly name?: string; /** Optional: introspection JSON text (if endpoint doesn't support introspection) */ readonly introspectionJson?: string; /** Namespace for the tools (derived from endpoint if not provided) */ readonly namespace?: string; /** Headers applied to every request. Values can reference secrets. */ readonly headers?: Record<string, HeaderValue>; /** Query parameters applied to every request. Values can reference secrets. */ readonly queryParams?: Record<string, QueryParamValue>; /** Optional OAuth2 connection used as a Bearer token for every request. */ readonly auth?: GraphqlSourceAuth; } // --------------------------------------------------------------------------- // Plugin extension // --------------------------------------------------------------------------- export interface GraphqlUpdateSourceInput { readonly name?: string; readonly endpoint?: string; readonly headers?: Record<string, HeaderValue>; readonly queryParams?: Record<string, QueryParamValue>; readonly auth?: GraphqlSourceAuth; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Derive a namespace from an endpoint URL */ const namespaceFromEndpoint = (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 namespace try { const url = new URL(endpoint); return url.hostname.replace(/[^a-z0-9]+/gi, "_").toLowerCase(); } catch { return "graphql"; } }; const formatTypeRef = (ref: IntrospectionTypeRef): string => { switch (ref.kind) { case "NON_NULL": return ref.ofType ? `${formatTypeRef(ref.ofType)}!` : "Unknown!"; case "LIST": return ref.ofType ? `[${formatTypeRef(ref.ofType)}]` : "[Unknown]"; default: return ref.name ?? "Unknown"; } }; const unwrapTypeName = (ref: IntrospectionTypeRef): string => { if (ref.name) return ref.name; if (ref.ofType) return unwrapTypeName(ref.ofType); return "Unknown"; }; const buildSelectionSet = ( ref: IntrospectionTypeRef, types: ReadonlyMap<string, IntrospectionType>, depth: number, seen: Set<string>, ): string => { if (depth > 2) return ""; const leafName = unwrapTypeName(ref); if (seen.has(leafName)) return ""; const objectType = types.get(leafName); if (!objectType?.fields) return ""; const kind = objectType.kind; if (kind === "SCALAR" || kind === "ENUM") return ""; seen.add(leafName); const subFields = objectType.fields .filter((f) => !f.name.startsWith("__")) .slice(0, 12) .map((f) => { const sub = buildSelectionSet(f.type, types, depth + 1, seen); return sub ? `${f.name} ${sub}` : f.name; }); seen.delete(leafName); return subFields.length > 0 ? `{ ${subFields.join(" ")} }` : ""; }; const buildOperationStringForField = ( kind: GraphqlOperationKind, field: IntrospectionField, types: ReadonlyMap<string, IntrospectionType>, ): string => { const opType = kind === "query" ? "query" : "mutation"; const varDefs = field.args.map((arg) => { const typeName = formatTypeRef(arg.type); return `$${arg.name}: ${typeName}`; }); const argPasses = field.args.map((arg) => `${arg.name}: $${arg.name}`); const selectionSet = buildSelectionSet(field.type, types, 0, new Set()); const varDefsStr = varDefs.length > 0 ? `(${varDefs.join(", ")})` : ""; const argPassStr = argPasses.length > 0 ? `(${argPasses.join(", ")})` : ""; return `${opType}${varDefsStr} { ${field.name}${argPassStr}${selectionSet ? ` ${selectionSet}` : ""} }`; }; interface PreparedOperation { readonly toolPath: string; readonly description: string; readonly inputSchema: unknown; readonly binding: OperationBinding; } const prepareOperations = ( fields: readonly ExtractedField[], introspection: IntrospectionResult, ): readonly PreparedOperation[] => { const typeMap = new Map<string, IntrospectionType>(); for (const t of introspection.__schema.types) { typeMap.set(t.name, t); } const fieldMap = new Map<string, { kind: GraphqlOperationKind; field: IntrospectionField }>(); const schema = introspection.__schema; for (const rootKind of ["query", "mutation"] as const) { const typeName = rootKind === "query" ? schema.queryType?.name : schema.mutationType?.name; if (!typeName) continue; const rootType = typeMap.get(typeName); if (!rootType?.fields) continue; for (const f of rootType.fields) { if (!f.name.startsWith("__")) { fieldMap.set(`${rootKind}.${f.name}`, { kind: rootKind, field: f }); } } } return fields.map((extracted) => { const prefix = extracted.kind === "mutation" ? "mutation" : "query"; const toolPath = `${prefix}.${extracted.fieldName}`; const description = Option.getOrElse( extracted.description, () => `GraphQL ${extracted.kind}: ${extracted.fieldName} -> ${extracted.returnTypeName}`, ); const key = `${extracted.kind}.${extracted.fieldName}`; const entry = fieldMap.get(key); const operationString = entry ? buildOperationStringForField(entry.kind, entry.field, typeMap) : `${extracted.kind} { ${extracted.fieldName} }`; const binding = new OperationBinding({ kind: extracted.kind, fieldName: extracted.fieldName, operationString, variableNames: extracted.arguments.map((a) => a.name), }); return { toolPath, description, inputSchema: Option.getOrUndefined(extracted.inputSchema), binding, }; }); }; const annotationsFor = (binding: OperationBinding): ToolAnnotations => { if (binding.kind === "mutation") { return { requiresApproval: true, approvalDescription: `mutation ${binding.fieldName}`, }; } return {}; }; // --------------------------------------------------------------------------- // Plugin factory // --------------------------------------------------------------------------- export interface GraphqlPluginOptions { readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; /** If provided, source add/remove is mirrored to executor.jsonc * (best-effort — file errors are logged, not raised). */ readonly configFile?: ConfigFileSink; } const toGraphqlConfigEntry = ( namespace: string, config: GraphqlSourceConfig, ): GraphqlConfigEntry => ({ kind: "graphql", endpoint: config.endpoint, introspectionJson: config.introspectionJson, namespace, headers: headersToConfigValues(config.headers), }); const makeGraphqlExtension = ( ctx: PluginCtx<GraphqlStore>, httpClientLayer: Layer.Layer<HttpClient.HttpClient>, configFile: ConfigFileSink | undefined, ) => { const resolveConfigValues = (values: Record<string, HeaderValue> | undefined) => Effect.gen(function* () { if (!values) return undefined; const resolved = yield* resolveHeaders(values, ctx.secrets); return Object.keys(resolved).length > 0 ? resolved : undefined; }); const resolveOAuthHeader = (auth: GraphqlSourceAuth | undefined) => Effect.gen(function* () { if (!auth || auth.kind === "none") return undefined; const accessToken = yield* ctx.connections.accessToken(auth.connectionId).pipe( Effect.mapError( () => new GraphqlIntrospectionError({ message: `Failed to resolve OAuth connection "${auth.connectionId}"`, }), ), ); return { Authorization: `Bearer ${accessToken}` }; }); const resolveRequestHeaders = ( headers: Record<string, HeaderValue> | undefined, auth: GraphqlSourceAuth | undefined, ) => Effect.gen(function* () { const resolvedHeaders = yield* resolveConfigValues(headers); const oauthHeader = yield* resolveOAuthHeader(auth); return { ...(resolvedHeaders ?? {}), ...(oauthHeader ?? {}) }; }); const addSourceInternal = (config: GraphqlSourceConfig) => ctx.transaction( Effect.gen(function* () { let introspectionResult: IntrospectionResult; if (config.introspectionJson) { introspectionResult = yield* parseIntrospectionJson(config.introspectionJson); } else { const resolvedHeaders = yield* resolveRequestHeaders(config.headers, config.auth); const resolvedQueryParams = yield* resolveConfigValues(config.queryParams); introspectionResult = yield* introspect( config.endpoint, Object.keys(resolvedHeaders).length > 0 ? resolvedHeaders : undefined, resolvedQueryParams, ).pipe(Effect.provide(httpClientLayer)); } const { result, definitions } = yield* extract(introspectionResult); const namespace = config.namespace ?? namespaceFromEndpoint(config.endpoint); const prepared = prepareOperations(result.fields, introspectionResult); const displayName = config.name?.trim() || namespace; const storedSource: StoredGraphqlSource = { namespace, scope: config.scope, name: displayName, endpoint: config.endpoint, headers: config.headers ?? {}, queryParams: config.queryParams ?? {}, auth: config.auth ?? { kind: "none" }, }; const storedOps: StoredOperation[] = prepared.map((p) => ({ toolId: `${namespace}.${p.toolPath}`, sourceId: namespace, binding: p.binding, })); yield* ctx.storage.upsertSource(storedSource, storedOps); yield* ctx.core.sources.register({ id: namespace, scope: config.scope, kind: "graphql", name: displayName, url: config.endpoint, canRemove: true, canRefresh: false, canEdit: true, tools: prepared.map((p) => ({ name: p.toolPath, description: p.description, inputSchema: p.inputSchema, })), }); if (Object.keys(definitions).length > 0) { yield* ctx.core.definitions.register({ sourceId: namespace, scope: config.scope, definitions, }); } return { toolCount: prepared.length, namespace }; }), ); return { addSource: (config: GraphqlSourceConfig) => addSourceInternal(config).pipe( Effect.tap((result) => configFile ? configFile.upsertSource(toGraphqlConfigEntry(result.namespace, config)) : Effect.void, ), ), removeSource: (namespace: string, scope: string) => Effect.gen(function* () { yield* ctx.transaction( Effect.gen(function* () { yield* ctx.storage.removeSource(namespace, scope); yield* ctx.core.sources.unregister(namespace); }), ); if (configFile) { yield* configFile.removeSource(namespace); } }), getSource: (namespace: string, scope: string) => ctx.storage.getSource(namespace, scope), updateSource: (namespace: string, scope: string, input: GraphqlUpdateSourceInput) => ctx.storage.updateSourceMeta(namespace, scope, { name: input.name?.trim() || undefined, endpoint: input.endpoint, headers: input.headers, queryParams: input.queryParams, auth: input.auth, }), }; }; export type GraphqlPluginExtension = ReturnType<typeof makeGraphqlExtension>; export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer; return { id: "graphql" as const, packageName: "@executor-js/plugin-graphql", schema: graphqlSchema, storage: (deps): GraphqlStore => makeDefaultGraphqlStore(deps), extension: (ctx) => makeGraphqlExtension(ctx, httpClientLayer, options?.configFile), staticSources: (self) => [ { id: "graphql", kind: "control", name: "GraphQL", tools: [ { name: "addSource", description: "Add a GraphQL endpoint and register its operations as tools", inputSchema: { type: "object", properties: { endpoint: { type: "string" }, name: { type: "string" }, introspectionJson: { type: "string" }, namespace: { type: "string" }, headers: { type: "object" }, queryParams: { type: "object" }, auth: { type: "object" }, }, required: ["endpoint"], }, outputSchema: { type: "object", properties: { toolCount: { type: "number" }, }, required: ["toolCount"], }, // Static-tool callers don't name a scope. Default to the // outermost scope in the executor's stack — for a single- // scope executor that's the only scope; for a per-user // stack `[user, org]` it writes at `org` so the source is // visible across every user. handler: ({ ctx, args }) => self.addSource({ ...(args as Omit<GraphqlSourceConfig, "scope">), scope: ctx.scopes.at(-1)!.id, }), }, ], }, ], invokeTool: ({ ctx, toolRow, args }) => Effect.gen(function* () { // toolRow.scope_id is the resolved owning scope of the tool // (innermost-wins from the executor's stack). The matching // graphql_operation + graphql_source rows live at the same // scope, so pin every store lookup to it instead of relying // on the scoped adapter's stack-wide fall-through. const toolScope = toolRow.scope_id; const op = yield* ctx.storage.getOperationByToolId(toolRow.id, toolScope); if (!op) { return yield* new GraphqlInvocationError({ message: `No GraphQL operation found for tool "${toolRow.id}"`, statusCode: Option.none(), }); } const source = yield* ctx.storage.getSource(op.sourceId, toolScope); if (!source) { return yield* new GraphqlInvocationError({ message: `No GraphQL source found for "${op.sourceId}"`, statusCode: Option.none(), }); } const resolvedHeaders = yield* resolveHeaders(source.headers, ctx.secrets); const resolvedQueryParams = yield* resolveHeaders(source.queryParams, ctx.secrets); if (source.auth.kind === "oauth2") { const accessToken = yield* ctx.connections.accessToken(source.auth.connectionId); resolvedHeaders.Authorization = `Bearer ${accessToken}`; } const result = yield* invokeWithLayer( op.binding, (args ?? {}) as Record<string, unknown>, source.endpoint,