History

Enhance tool source support by adding GraphQL type integration

- Updated ToolSourceRecord and related interfaces to include "graphql" as a valid type. - Modified database mapping and service logic to accommodate GraphQL tool sources. - Enhanced API and UI components to support GraphQL endpoint configuration and validation. - Implemented GraphQL-specific logic for tool approval decisions and introspection handling.

Rhys Sullivan committed 9 months ago

Commit 82011a31 parent 0a932c89 files · +559 −58Browse files

Unified · Split

9 files
  1. executor/apps/server/src/database.ts +2 −2
  2. executor/apps/server/src/index.ts +3 −3
  3. executor/apps/server/src/service.ts +101 −34
  4. executor/apps/server/src/tool-sources.ts +404 −2
  5. executor/apps/server/src/types.ts +7 −1
  6. executor/apps/web/src/components/tools-view.tsx +35 −13
  7. executor/apps/web/src/lib/api.ts +1 −1
  8. executor/apps/web/src/lib/types.ts +3 −1
  9. executor/packages/contracts/src/index.ts +3 −1
executor/apps/server/src/database.ts+2−2
@@ -106,7 +106,7 @@ function mapToolSourceRow(row: Record<string, unknown>): ToolSourceRecord { · 105 unmodified lines
106106 id: String(row.id),
107107 workspaceId: String(row.workspace_id),
108108 name: String(row.name),
109− type: String(row.type) as "mcp" | "openapi",
109+ type: String(row.type) as ToolSourceRecord["type"],
110110 config: parseMetadata(row.config_json),
111111 enabled: Number(row.enabled) === 1,
112112 createdAt: Number(row.created_at),
@@ -700,7 +700,7 @@ export class ExecutorDatabase { · 587 unmodified lines
700700 id?: string;
701701 workspaceId: string;
702702 name: string;
703− type: "mcp" | "openapi";
703+ type: ToolSourceRecord["type"];
704704 config: Record<string, unknown>;
705705 enabled?: boolean;
706706 }): ToolSourceRecord {
executor/apps/server/src/index.ts+3−3
@@ -213,7 +213,7 @@ const server = Bun.serve({ · 212 unmodified lines
213213 id?: string;
214214 workspaceId?: string;
215215 name?: string;
216− type?: "mcp" | "openapi";
216+ type?: "mcp" | "openapi" | "graphql";
217217 config?: Record<string, unknown>;
218218 enabled?: boolean;
219219 }>(request);
@@ -222,8 +222,8 @@ const server = Bun.serve({ · 2 unmodified lines
222222 return error(400, "workspaceId, name, type, and config are required");
223223 }
224224
225− if (body.type !== "mcp" && body.type !== "openapi") {
226− return error(400, "type must be 'mcp' or 'openapi'");
225+ if (body.type !== "mcp" && body.type !== "openapi" && body.type !== "graphql") {
226+ return error(400, "type must be 'mcp', 'openapi', or 'graphql'");
227227 }
228228
229229 try {
executor/apps/server/src/service.ts+101−34
@@ -4,7 +4,7 @@ import { InProcessExecutionAdapter } from "./adapters/in-process-execution-adapt · 3 unmodified lines
44 import { APPROVAL_DENIED_PREFIX } from "./execution-constants";
55 import { createDiscoverTool } from "./tool-discovery";
66 import type { ExternalToolSourceConfig } from "./tool-sources";
7−import { loadExternalTools } from "./tool-sources";
7+import { loadExternalTools, parseGraphqlOperationPaths } from "./tool-sources";
88 import type {
99 AccessPolicyRecord,
1010 AnonymousContext,
@@ -22,6 +22,7 @@ import type { · 11 unmodified lines
2222 ToolCallRequest,
2323 ToolDefinition,
2424 ToolDescriptor,
25+ ToolSourceRecord,
2526 RuntimeOutputEvent,
2627 PolicyDecision,
2728 ResolvedToolCredential,
@@ -75,7 +76,7 @@ function sourceSignature(workspaceId: string, sources: Array<{ id: string; updat · 47 unmodified lines
7576 }
7677
7778 function normalizeExternalToolSource(raw: {
78− type: "mcp" | "openapi";
79+ type: ToolSourceRecord["type"];
7980 name: string;
8081 config: Record<string, unknown>;
8182 }): ExternalToolSourceConfig {
@@ -92,6 +93,13 @@ function normalizeExternalToolSource(raw: { · 10 unmodified lines
9293 return merged as unknown as ExternalToolSourceConfig;
9394 }
9495
96+ if (raw.type === "graphql") {
97+ if (typeof merged.endpoint !== "string" || merged.endpoint.trim().length === 0) {
98+ throw new Error(`GraphQL source '${raw.name}' missing endpoint`);
99+ }
100+ return merged as unknown as ExternalToolSourceConfig;
101+ }
102+
95103 const spec = merged.spec;
96104 if (typeof spec !== "string" && typeof spec !== "object") {
97105 throw new Error(`OpenAPI source '${raw.name}' missing spec`);
@@ -192,16 +200,7 @@ export class ExecutorService { · 94 unmodified lines
192200 }));
193201 }
194202
195− listToolSources(workspaceId: string): Array<{
196− id: string;
197− workspaceId: string;
198− name: string;
199− type: "mcp" | "openapi";
200− enabled: boolean;
201− config: Record<string, unknown>;
202− createdAt: number;
203− updatedAt: number;
204− }> {
203+ listToolSources(workspaceId: string): ToolSourceRecord[] {
205204 return this.db.listToolSources(workspaceId);
206205 }
207206
@@ -209,19 +208,10 @@ export class ExecutorService { · 1 unmodified line
209208 id?: string;
210209 workspaceId: string;
211210 name: string;
212− type: "mcp" | "openapi";
211+ type: ToolSourceRecord["type"];
213212 config: Record<string, unknown>;
214213 enabled?: boolean;
215− }): Promise<{
216− id: string;
217− workspaceId: string;
218− name: string;
219− type: "mcp" | "openapi";
220− enabled: boolean;
221− config: Record<string, unknown>;
222− createdAt: number;
223− updatedAt: number;
224− }> {
214+ }): Promise<ToolSourceRecord & { warnings?: string[] }> {
225215 const source = this.db.upsertToolSource(input);
226216 this.workspaceToolCache.delete(source.workspaceId);
227217 await this.getWorkspaceTools(source.workspaceId);
@@ -597,6 +587,69 @@ export class ExecutorService { · 369 unmodified lines
597587 };
598588 }
599589
590+ /**
591+ * For GraphQL tools with _graphqlSource, resolve the approval decision
592+ * by parsing the query and checking policies against each virtual field path
593+ * (e.g. linear.mutation.issueCreate, linear.query.issues).
594+ *
595+ * Returns the most restrictive decision across all field paths.
596+ */
597+ private getGraphqlDecision(
598+ task: TaskRecord,
599+ tool: ToolDefinition,
600+ input: unknown,
601+ workspaceTools: Map<string, ToolDefinition>,
602+ ): { decision: PolicyDecision; effectivePaths: string[] } {
603+ const sourceName = tool._graphqlSource!;
604+ const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
605+ const queryString = typeof payload.query === "string" ? payload.query : "";
606+
607+ if (!queryString.trim()) {
608+ // No query to parse — fall back to tool's own approval
609+ return { decision: this.getToolDecision(task, tool), effectivePaths: [tool.path] };
610+ }
611+
612+ const { fieldPaths } = parseGraphqlOperationPaths(sourceName, queryString);
613+ if (fieldPaths.length === 0) {
614+ return { decision: this.getToolDecision(task, tool), effectivePaths: [tool.path] };
615+ }
616+
617+ const policies = this.db.listAccessPolicies(task.workspaceId);
618+ let worstDecision: PolicyDecision = "allow";
619+
620+ for (const fieldPath of fieldPaths) {
621+ // Look up the pseudo-tool for this field path to get its default approval
622+ const pseudoTool = workspaceTools.get(fieldPath);
623+ const fieldDecision = pseudoTool
624+ ? this.getDecisionForContext(pseudoTool, {
625+ workspaceId: task.workspaceId,
626+ actorId: task.actorId,
627+ clientId: task.clientId,
628+ }, policies)
629+ : // Unknown field — check if any policies match, otherwise default to mutation=required
630+ this.getDecisionForContext(
631+ { ...tool, path: fieldPath, approval: fieldPath.includes(".mutation.") ? "required" : "auto" },
632+ {
633+ workspaceId: task.workspaceId,
634+ actorId: task.actorId,
635+ clientId: task.clientId,
636+ },
637+ policies,
638+ );
639+
640+ // Escalate: deny > require_approval > allow
641+ if (fieldDecision === "deny") {
642+ worstDecision = "deny";
643+ break; // Can't get worse
644+ }
645+ if (fieldDecision === "require_approval") {
646+ worstDecision = "require_approval";
647+ }
648+ }
649+
650+ return { decision: worstDecision, effectivePaths: fieldPaths };
651+ }
652+
600653 private async invokeTool(task: TaskRecord, call: ToolCallRequest): Promise<unknown> {
601654 const { toolPath, input, callId } = call;
602655 const workspaceTools = await this.getWorkspaceTools(task.workspaceId);
@@ -605,15 +658,29 @@ export class ExecutorService { · 2 unmodified lines
605658 throw new Error(`Unknown tool: ${toolPath}`);
606659 }
607660
608− const decision = this.getToolDecision(task, tool);
661+ // Determine approval decision — for GraphQL tools, parse the query for granular paths
662+ let decision: PolicyDecision;
663+ let effectiveToolPath = toolPath;
664+
665+ if (tool._graphqlSource) {
666+ const result = this.getGraphqlDecision(task, tool, input, workspaceTools);
667+ decision = result.decision;
668+ // Use the field paths for event reporting so approvals show what's actually being called
669+ if (result.effectivePaths.length > 0) {
670+ effectiveToolPath = result.effectivePaths.join(", ");
671+ }
672+ } else {
673+ decision = this.getToolDecision(task, tool);
674+ }
675+
609676 if (decision === "deny") {
610677 this.publish(task.id, "task", "tool.call.denied", {
611678 taskId: task.id,
612679 callId,
613− toolPath,
680+ toolPath: effectiveToolPath,
614681 reason: "policy_deny",
615682 });
616− throw new Error(`${APPROVAL_DENIED_PREFIX}${toolPath} (policy denied)`);
683+ throw new Error(`${APPROVAL_DENIED_PREFIX}${effectiveToolPath} (policy denied)`);
617684 }
618685
619686 let credential: ResolvedToolCredential | undefined;
@@ -630,7 +697,7 @@ export class ExecutorService { · 10 unmodified lines
630697 this.publish(task.id, "task", "tool.call.started", {
631698 taskId: task.id,
632699 callId,
633− toolPath,
700+ toolPath: effectiveToolPath,
634701 approval: decision === "require_approval" ? "required" : "auto",
635702 input: asPayload(input),
636703 });
@@ -639,7 +706,7 @@ export class ExecutorService { · 2 unmodified lines
639706 const approval = this.db.createApproval({
640707 id: createApprovalId(),
641708 taskId: task.id,
642− toolPath,
709+ toolPath: effectiveToolPath,
643710 input,
644711 });
645712
@@ -652,15 +719,15 @@ export class ExecutorService { · 6 unmodified lines
652719 createdAt: approval.createdAt,
653720 });
654721
655− const decision = await this.waitForApproval(approval.id);
656− if (decision === "denied") {
722+ const approvalDecision = await this.waitForApproval(approval.id);
723+ if (approvalDecision === "denied") {
657724 this.publish(task.id, "task", "tool.call.denied", {
658725 taskId: task.id,
659726 callId,
660− toolPath,
727+ toolPath: effectiveToolPath,
661728 approvalId: approval.id,
662729 });
663− throw new Error(`${APPROVAL_DENIED_PREFIX}${toolPath} (${approval.id})`);
730+ throw new Error(`${APPROVAL_DENIED_PREFIX}${effectiveToolPath} (${approval.id})`);
664731 }
665732 }
666733
@@ -677,7 +744,7 @@ export class ExecutorService { · 10 unmodified lines
677744 this.publish(task.id, "task", "tool.call.completed", {
678745 taskId: task.id,
679746 callId,
680− toolPath,
747+ toolPath: effectiveToolPath,
681748 output: asPayload(value),
682749 });
683750 return value;
@@ -686,7 +753,7 @@ export class ExecutorService { · 2 unmodified lines
686753 this.publish(task.id, "task", "tool.call.failed", {
687754 taskId: task.id,
688755 callId,
689− toolPath,
756+ toolPath: effectiveToolPath,
690757 error: message,
691758 });
692759 throw error;
executor/apps/server/src/tool-sources.ts+404−2
@@ -32,7 +32,22 @@ export interface OpenApiToolSourceConfig { · 31 unmodified lines
3232 overrides?: Record<string, { approval?: ToolApprovalMode }>;
3333 }
3434
35−export type ExternalToolSourceConfig = McpToolSourceConfig | OpenApiToolSourceConfig;
35+export interface GraphqlToolSourceConfig {
36+ type: "graphql";
37+ name: string;
38+ endpoint: string;
39+ /** Optional static introspection result — if omitted, we introspect at load time */
40+ schema?: Record<string, unknown>;
41+ auth?: OpenApiAuth;
42+ defaultQueryApproval?: ToolApprovalMode;
43+ defaultMutationApproval?: ToolApprovalMode;
44+ overrides?: Record<string, { approval?: ToolApprovalMode }>;
45+}
46+
47+export type ExternalToolSourceConfig =
48+ | McpToolSourceConfig
49+ | OpenApiToolSourceConfig
50+ | GraphqlToolSourceConfig;
3651
3752 function toObject(value: unknown): Record<string, unknown> {
3853 return value && typeof value === "object" && !Array.isArray(value)
@@ -398,6 +413,391 @@ async function loadOpenApiTools(config: OpenApiToolSourceConfig): Promise<ToolDe · 359 unmodified lines
398413 return tools;
399414 }
400415
416+// ── GraphQL introspection ──
417+
418+const INTROSPECTION_QUERY = `
419+ query IntrospectionQuery {
420+ __schema {
421+ queryType { name }
422+ mutationType { name }
423+ types {
424+ kind name
425+ fields {
426+ name description
427+ args { name description type { ...TypeRef } defaultValue }
428+ type { ...TypeRef }
429+ }
430+ inputFields {
431+ name description
432+ type { ...TypeRef }
433+ defaultValue
434+ }
435+ enumValues { name description }
436+ }
437+ }
438+ }
439+ fragment TypeRef on __Type {
440+ kind name
441+ ofType {
442+ kind name
443+ ofType {
444+ kind name
445+ ofType {
446+ kind name
447+ ofType { kind name }
448+ }
449+ }
450+ }
451+ }
452+`;
453+
454+interface GqlTypeRef {
455+ kind: string;
456+ name: string | null;
457+ ofType?: GqlTypeRef | null;
458+}
459+
460+interface GqlField {
461+ name: string;
462+ description: string | null;
463+ args: Array<{
464+ name: string;
465+ description: string | null;
466+ type: GqlTypeRef;
467+ defaultValue: string | null;
468+ }>;
469+ type: GqlTypeRef;
470+}
471+
472+interface GqlInputField {
473+ name: string;
474+ description: string | null;
475+ type: GqlTypeRef;
476+ defaultValue: string | null;
477+}
478+
479+interface GqlEnumValue {
480+ name: string;
481+ description: string | null;
482+}
483+
484+interface GqlType {
485+ kind: string;
486+ name: string;
487+ fields: GqlField[] | null;
488+ inputFields: GqlInputField[] | null;
489+ enumValues: GqlEnumValue[] | null;
490+}
491+
492+interface GqlSchema {
493+ queryType: { name: string } | null;
494+ mutationType: { name: string } | null;
495+ types: GqlType[];
496+}
497+
498+/** Resolve a GqlTypeRef to the underlying named type (unwrapping NON_NULL/LIST wrappers) */
499+function unwrapType(ref: GqlTypeRef): string | null {
500+ if (ref.kind === "NON_NULL" && ref.ofType) return unwrapType(ref.ofType);
501+ if (ref.kind === "LIST" && ref.ofType) return unwrapType(ref.ofType);
502+ return ref.name;
503+}
504+
505+/**
506+ * Convert a GraphQL type reference to a TypeScript-like type hint,
507+ * recursively expanding INPUT_OBJECT types so the model sees actual fields.
508+ */
509+function gqlTypeToHint(ref: GqlTypeRef, typeMap?: Map<string, GqlType>, depth = 0): string {
510+ if (ref.kind === "NON_NULL" && ref.ofType) return gqlTypeToHint(ref.ofType, typeMap, depth);
511+ if (ref.kind === "LIST" && ref.ofType) return `${gqlTypeToHint(ref.ofType, typeMap, depth)}[]`;
512+
513+ if (ref.name && typeMap && depth < 3) {
514+ const resolved = typeMap.get(ref.name);
515+ if (resolved?.kind === "INPUT_OBJECT" && resolved.inputFields) {
516+ return expandInputObject(resolved, typeMap, depth);
517+ }
518+ if (resolved?.kind === "ENUM" && resolved.enumValues && resolved.enumValues.length > 0) {
519+ const values = resolved.enumValues.slice(0, 8).map((v) => `"${v.name}"`);
520+ const suffix = resolved.enumValues.length > 8 ? " | ..." : "";
521+ return values.join(" | ") + suffix;
522+ }
523+ }
524+
525+ // Map common GraphQL scalars to TS primitives
526+ if (ref.name) {
527+ switch (ref.name) {
528+ case "String":
529+ case "ID":
530+ case "DateTime":
531+ case "Date":
532+ case "UUID":
533+ case "JSONString":
534+ case "TimelessDate":
535+ return "string";
536+ case "Int":
537+ case "Float":
538+ return "number";
539+ case "Boolean":
540+ return "boolean";
541+ case "JSON":
542+ case "JSONObject":
543+ return "Record<string, unknown>";
544+ default:
545+ return ref.name;
546+ }
547+ }
548+ return "unknown";
549+}
550+
551+function expandInputObject(type: GqlType, typeMap: Map<string, GqlType>, depth: number): string {
552+ const fields = type.inputFields;
553+ if (!fields || fields.length === 0) return "Record<string, unknown>";
554+ const entries = fields.slice(0, 16).map((f) => {
555+ const required = f.type.kind === "NON_NULL";
556+ return `${f.name}${required ? "" : "?"}: ${gqlTypeToHint(f.type, typeMap, depth + 1)}`;
557+ });
558+ const suffix = fields.length > 16 ? "; ..." : "";
559+ return `{ ${entries.join("; ")}${suffix} }`;
560+}
561+
562+function gqlFieldArgsTypeHint(args: GqlField["args"], typeMap?: Map<string, GqlType>): string {
563+ if (args.length === 0) return "{}";
564+ const entries = args.slice(0, 12).map((a) => {
565+ const required = a.type.kind === "NON_NULL";
566+ return `${a.name}${required ? "" : "?"}: ${gqlTypeToHint(a.type, typeMap)}`;
567+ });
568+ return `{ ${entries.join("; ")} }`;
569+}
570+
571+function isRequired(ref: GqlTypeRef): boolean {
572+ return ref.kind === "NON_NULL";
573+}
574+
575+/** Build a minimal GraphQL document for a single root field with its arguments */
576+function buildFieldQuery(
577+ operationType: "query" | "mutation",
578+ fieldName: string,
579+ args: GqlField["args"],
580+): string {
581+ if (args.length === 0) {
582+ return `${operationType} { ${fieldName} }`;
583+ }
584+ const varDefs = args.map((a) => `$${a.name}: ${printGqlType(a.type)}`).join(", ");
585+ const fieldArgs = args.map((a) => `${a.name}: $${a.name}`).join(", ");
586+ return `${operationType}(${varDefs}) { ${fieldName}(${fieldArgs}) }`;
587+}
588+
589+function printGqlType(ref: GqlTypeRef): string {
590+ if (ref.kind === "NON_NULL" && ref.ofType) return `${printGqlType(ref.ofType)}!`;
591+ if (ref.kind === "LIST" && ref.ofType) return `[${printGqlType(ref.ofType)}]`;
592+ return ref.name ?? "String";
593+}
594+
595+/**
596+ * Parse a GraphQL query string to extract the operation type and root field names.
597+ * This is intentionally simple — no full parser needed, just enough for policy routing.
598+ */
599+export function parseGraphqlOperationPaths(
600+ sourceName: string,
601+ queryString: string,
602+): { operationType: "query" | "mutation" | "subscription"; fieldPaths: string[] } {
603+ const trimmed = queryString.trim();
604+
605+ // Determine operation type
606+ let operationType: "query" | "mutation" | "subscription" = "query";
607+ if (/^mutation\b/i.test(trimmed)) operationType = "mutation";
608+ else if (/^subscription\b/i.test(trimmed)) operationType = "subscription";
609+
610+ // Find the first { ... } block and extract top-level field names
611+ const braceStart = trimmed.indexOf("{");
612+ if (braceStart === -1) return { operationType, fieldPaths: [] };
613+
614+ // Walk the content inside the first braces, extract field names at depth 0
615+ const content = trimmed.slice(braceStart + 1);
616+ const fieldPaths: string[] = [];
617+ let depth = 0;
618+ let current = "";
619+
620+ for (const char of content) {
621+ if (char === "{") {
622+ if (depth === 0 && current.trim()) {
623+ // Grab the field name (before any args in parens)
624+ const fieldName = current.trim().split(/[\s(]/)[0];
625+ if (fieldName && !fieldName.startsWith("__")) {
626+ fieldPaths.push(`${sanitizeSegment(sourceName)}.${operationType}.${sanitizeSegment(fieldName)}`);
627+ }
628+ }
629+ depth++;
630+ current = "";
631+ } else if (char === "}") {
632+ if (depth === 0) {
633+ // End of top-level block — grab last field if any
634+ const fieldName = current.trim().split(/[\s(]/)[0];
635+ if (fieldName && !fieldName.startsWith("__")) {
636+ fieldPaths.push(`${sanitizeSegment(sourceName)}.${operationType}.${sanitizeSegment(fieldName)}`);
637+ }
638+ break;
639+ }
640+ depth--;
641+ current = "";
642+ } else if (depth === 0) {
643+ if (char === "\n" || char === ",") {
644+ const fieldName = current.trim().split(/[\s(]/)[0];
645+ if (fieldName && !fieldName.startsWith("__")) {
646+ fieldPaths.push(`${sanitizeSegment(sourceName)}.${operationType}.${sanitizeSegment(fieldName)}`);
647+ }
648+ current = "";
649+ } else {
650+ current += char;
651+ }
652+ }
653+ }
654+
655+ return { operationType, fieldPaths };
656+}
657+
658+async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDefinition[]> {
659+ const authHeaders = buildStaticAuthHeaders(config.auth);
660+ const sourceKey = `graphql:${config.name}`;
661+ const credentialSpec = buildCredentialSpec(sourceKey, config.auth);
662+ const sourceName = sanitizeSegment(config.name);
663+
664+ // Introspect the schema
665+ const introspectionResult = await fetch(config.endpoint, {
666+ method: "POST",
667+ headers: {
668+ "content-type": "application/json",
669+ ...authHeaders,
670+ },
671+ body: JSON.stringify({ query: INTROSPECTION_QUERY }),
672+ });
673+
674+ if (!introspectionResult.ok) {
675+ const text = await introspectionResult.text().catch(() => "");
676+ throw new Error(`GraphQL introspection failed: HTTP ${introspectionResult.status}: ${text.slice(0, 300)}`);
677+ }
678+
679+ const introspectionJson = (await introspectionResult.json()) as { data?: { __schema?: GqlSchema }; errors?: unknown[] };
680+ if (introspectionJson.errors) {
681+ throw new Error(`GraphQL introspection errors: ${JSON.stringify(introspectionJson.errors).slice(0, 500)}`);
682+ }
683+ const schema = introspectionJson.data?.__schema;
684+ if (!schema) {
685+ throw new Error("GraphQL introspection returned no schema");
686+ }
687+
688+ // Index types by name
689+ const typeMap = new Map<string, GqlType>();
690+ for (const t of schema.types) {
691+ typeMap.set(t.name, t);
692+ }
693+
694+ const tools: ToolDefinition[] = [];
695+
696+ // Create the main graphql tool — this is the one that actually executes queries
697+ const mainToolPath = `${sourceName}.graphql`;
698+ tools.push({
699+ path: mainToolPath,
700+ source: sourceKey,
701+ description: `Execute a GraphQL query or mutation against ${config.name}. Use the ${sourceName}.query.* and ${sourceName}.mutation.* tool descriptions to see available operations.`,
702+ approval: "auto", // Actual approval is determined dynamically per-invocation
703+ metadata: {
704+ argsType: "{ query: string; variables?: Record<string, unknown> }",
705+ returnsType: "unknown",
706+ },
707+ credential: credentialSpec,
708+ // Tag as graphql source so invokeTool knows to do dynamic path extraction
709+ _graphqlSource: config.name,
710+ run: async (input: unknown, context) => {
711+ const payload = toObject(input);
712+ const query = String(payload.query ?? "");
713+ const variables = payload.variables ?? undefined;
714+
715+ if (!query.trim()) {
716+ throw new Error("GraphQL query string is required");
717+ }
718+
719+ const response = await fetch(config.endpoint, {
720+ method: "POST",
721+ headers: {
722+ "content-type": "application/json",
723+ ...authHeaders,
724+ ...(context.credential?.headers ?? {}),
725+ },
726+ body: JSON.stringify({ query, variables }),
727+ });
728+
729+ if (!response.ok) {
730+ const text = await response.text().catch(() => "");
731+ throw new Error(`HTTP ${response.status} ${response.statusText}: ${text.slice(0, 500)}`);
732+ }
733+
734+ const result = await response.json() as { data?: unknown; errors?: unknown[] };
735+ if (result.errors && (!result.data || Object.keys(result.data as object).length === 0)) {
736+ throw new Error(`GraphQL errors: ${JSON.stringify(result.errors).slice(0, 1000)}`);
737+ }
738+ // Return both data and errors if partial
739+ if (result.errors) return result;
740+ return result.data;
741+ },
742+ } as ToolDefinition & { _graphqlSource: string });
743+
744+ // Create pseudo-tools for each query/mutation field — these are for discovery/intellisense
745+ // but they all route through the main .graphql tool
746+ const rootTypes: Array<{ typeName: string | null; operationType: "query" | "mutation" }> = [
747+ { typeName: schema.queryType?.name ?? null, operationType: "query" },
748+ { typeName: schema.mutationType?.name ?? null, operationType: "mutation" },
749+ ];
750+
751+ for (const { typeName, operationType } of rootTypes) {
752+ if (!typeName) continue;
753+ const rootType = typeMap.get(typeName);
754+ if (!rootType?.fields) continue;
755+
756+ const defaultApproval = operationType === "query"
757+ ? (config.defaultQueryApproval ?? "auto")
758+ : (config.defaultMutationApproval ?? "required");
759+
760+ for (const field of rootType.fields) {
761+ if (field.name.startsWith("__")) continue;
762+
763+ const fieldPath = `${sourceName}.${operationType}.${sanitizeSegment(field.name)}`;
764+ const approval = config.overrides?.[field.name]?.approval ?? defaultApproval;
765+
766+ // Build the example query for the description
767+ const exampleQuery = buildFieldQuery(operationType, field.name, field.args);
768+
769+ tools.push({
770+ path: fieldPath,
771+ source: sourceKey,
772+ description: field.description
773+ ? `${field.description}\n\nExample: ${sourceName}.graphql({ query: \`${exampleQuery}\`, variables: {...} })`
774+ : `GraphQL ${operationType}: ${field.name}\n\nExample: ${sourceName}.graphql({ query: \`${exampleQuery}\`, variables: {...} })`,
775+ approval,
776+ metadata: {
777+ argsType: gqlFieldArgsTypeHint(field.args, typeMap),
778+ returnsType: gqlTypeToHint(field.type, typeMap),
779+ },
780+ // Pseudo-tools don't have a run — they exist for discovery and policy matching only
781+ _pseudoTool: true,
782+ run: async (input: unknown, context) => {
783+ // If someone calls this directly, delegate to the main graphql tool
784+ const payload = toObject(input);
785+ if (!payload.query) {
786+ // Auto-build the query from the variables
787+ payload.query = buildFieldQuery(operationType, field.name, field.args);
788+ }
789+ // Find and invoke the main tool
790+ const mainTool = tools.find((t) => t.path === mainToolPath);
791+ if (!mainTool) throw new Error(`Main GraphQL tool not found`);
792+ return mainTool.run(payload, context);
793+ },
794+ } as ToolDefinition & { _pseudoTool: boolean });
795+ }
796+ }
797+
798+ return tools;
799+}
800+
401801 export function parseToolSourcesFromEnv(raw: string | undefined): ExternalToolSourceConfig[] {
402802 if (!raw || raw.trim().length === 0) {
403803 return [];
@@ -411,7 +811,7 @@ export function parseToolSourcesFromEnv(raw: string | undefined): ExternalToolSo · 7 unmodified lines
411811 return parsed as ExternalToolSourceConfig[];
412812 }
413813
414−export async function loadExternalTools(sources: ExternalToolSourceConfig[]): Promise<ToolDefinition[]> {
814+export async function loadExternalTools(sources: ExternalToolSourceConfig[]): Promise<{ tools: ToolDefinition[]; warnings: string[] }> {
415815 const loaded: ToolDefinition[] = [];
416816 const warnings: string[] = [];
417817
@@ -421,6 +821,8 @@ export async function loadExternalTools(sources: ExternalToolSourceConfig[]): Pr · 3 unmodified lines
421821 loaded.push(...(await loadMcpTools(source)));
422822 } else if (source.type === "openapi") {
423823 loaded.push(...(await loadOpenApiTools(source)));
824+ } else if (source.type === "graphql") {
825+ loaded.push(...(await loadGraphqlTools(source)));
424826 }
425827 } catch (error) {
426828 const message = error instanceof Error ? error.message : String(error);
executor/apps/server/src/types.ts+7−1
@@ -188,6 +188,10 @@ export interface ToolDefinition { · 187 unmodified lines
188188 source?: string;
189189 metadata?: ToolTypeMetadata;
190190 credential?: ToolCredentialSpec;
191+ /** For GraphQL sources: the source name used for dynamic path extraction */
192+ _graphqlSource?: string;
193+ /** For GraphQL pseudo-tools: marks tools that exist only for discovery/policy */
194+ _pseudoTool?: boolean;
191195 run(input: unknown, context: ToolRunContext): Promise<unknown>;
192196 }
193197
@@ -200,11 +204,13 @@ export interface ToolDescriptor { · 6 unmodified lines
200204 returnsType?: string;
201205 }
202206
207+export type ToolSourceType = "mcp" | "openapi" | "graphql";
208+
203209 export interface ToolSourceRecord {
204210 id: string;
205211 workspaceId: string;
206212 name: string;
207− type: "mcp" | "openapi";
213+ type: ToolSourceType;
208214 config: Record<string, unknown>;
209215 enabled: boolean;
210216 createdAt: number;
executor/apps/web/src/components/tools-view.tsx+35−13
@@ -55,9 +55,10 @@ interface ApiPreset { · 54 unmodified lines
5555 name: string;
5656 label: string;
5757 description: string;
58− type: "openapi" | "mcp";
58+ type: "openapi" | "mcp" | "graphql";
5959 spec?: string;
6060 url?: string;
61+ endpoint?: string;
6162 baseUrl?: string;
6263 authNote?: string;
6364 }
@@ -181,6 +182,14 @@ const API_PRESETS: ApiPreset[] = [ · 117 unmodified lines
181182 spec: "https://raw.githubusercontent.com/resend/resend-openapi/main/resend.yaml",
182183 baseUrl: "https://api.resend.com",
183184 },
185+ {
186+ name: "linear",
187+ label: "Linear",
188+ description: "Issues, projects, teams, cycles, labels",
189+ type: "graphql",
190+ endpoint: "https://api.linear.app/graphql",
191+ authNote: "Requires API key as bearer credential",
192+ },
184193 ];
185194
186195 /** Derive a favicon URL from any URL string via Google's favicon service. */
@@ -195,14 +204,16 @@ function faviconForUrl(url: string | undefined | null): string | null { · 8 unmodified lines
195204 }
196205
197206 function getFaviconUrl(preset: ApiPreset): string | null {
198− return faviconForUrl(preset.baseUrl ?? preset.url);
207+ return faviconForUrl(preset.baseUrl ?? preset.endpoint ?? preset.url);
199208 }
200209
201210 function getSourceFavicon(source: ToolSourceRecord): string | null {
202211 const url =
203212 source.type === "mcp"
204213 ? (source.config.url as string)
205− : (source.config.baseUrl as string) ?? (source.config.spec as string);
214+ : source.type === "graphql"
215+ ? (source.config.endpoint as string)
216+ : (source.config.baseUrl as string) ?? (source.config.spec as string);
206217 return faviconForUrl(url);
207218 }
208219
@@ -249,7 +260,7 @@ function AddSourceDialog({ · 40 unmodified lines
249260 const { context } = useSession();
250261 const [open, setOpen] = useState(false);
251262 const [presetsOpen, setPresetsOpen] = useState(false);
252− const [type, setType] = useState<"mcp" | "openapi">("mcp");
263+ const [type, setType] = useState<"mcp" | "openapi" | "graphql">("mcp");
253264 const [name, setName] = useState("");
254265 const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
255266 const [endpoint, setEndpoint] = useState("");
@@ -286,7 +297,7 @@ function AddSourceDialog({ · 30 unmodified lines
286297
287298 const addSource = async (
288299 sourceName: string,
289− sourceType: "mcp" | "openapi",
300+ sourceType: "mcp" | "openapi" | "graphql",
290301 config: Record<string, unknown>,
291302 ) => {
292303 if (!context) return;
@@ -314,10 +325,12 @@ function AddSourceDialog({ · 21 unmodified lines
314325 const config: Record<string, unknown> =
315326 preset.type === "mcp"
316327 ? { url: preset.url }
317− : {
318− spec: preset.spec,
319− ...(preset.baseUrl ? { baseUrl: preset.baseUrl } : {}),
320− };
328+ : preset.type === "graphql"
329+ ? { endpoint: preset.endpoint }
330+ : {
331+ spec: preset.spec,
332+ ...(preset.baseUrl ? { baseUrl: preset.baseUrl } : {}),
333+ };
321334 await addSource(preset.name, preset.type, config);
322335 if (preset.authNote) {
323336 toast.info(preset.authNote, { duration: 6000 });
@@ -337,7 +350,9 @@ function AddSourceDialog({ · 13 unmodified lines
337350 const config: Record<string, unknown> =
338351 type === "mcp"
339352 ? { url: endpoint }
340− : { spec: endpoint, ...(baseUrl ? { baseUrl } : {}) };
353+ : type === "graphql"
354+ ? { endpoint: endpoint }
355+ : { spec: endpoint, ...(baseUrl ? { baseUrl } : {}) };
341356 await addSource(name.trim(), type, config);
342357 resetForm();
343358 setOpen(false);
@@ -383,12 +398,15 @@ function AddSourceDialog({ · 39 unmodified lines
383398 <SelectItem value="openapi" className="text-xs">
384399 OpenAPI Spec
385400 </SelectItem>
401+ <SelectItem value="graphql" className="text-xs">
402+ GraphQL
403+ </SelectItem>
386404 </SelectContent>
387405 </Select>
388406 </div>
389407 <div className="space-y-1.5">
390408 <Label className="text-xs text-muted-foreground">
391− {type === "mcp" ? "Endpoint URL" : "Spec URL"}
409+ {type === "mcp" ? "Endpoint URL" : type === "graphql" ? "GraphQL Endpoint" : "Spec URL"}
392410 </Label>
393411 <Input
394412 value={endpoint}
@@ -396,7 +414,9 @@ function AddSourceDialog({ · 1 unmodified line
396414 placeholder={
397415 type === "mcp"
398416 ? "https://mcp-server.example.com/sse"
399− : "https://api.example.com/openapi.json"
417+ : type === "graphql"
418+ ? "https://api.example.com/graphql"
419+ : "https://api.example.com/openapi.json"
400420 }
401421 className="h-8 text-xs font-mono bg-background"
402422 />
@@ -577,7 +597,9 @@ function SourceCard({ · 174 unmodified lines
577597 <span className="text-[11px] text-muted-foreground font-mono truncate block">
578598 {source.type === "mcp"
579599 ? (source.config.url as string)
580− : (source.config.spec as string)}
600+ : source.type === "graphql"
601+ ? (source.config.endpoint as string)
602+ : (source.config.spec as string)}
581603 </span>
582604 </div>
583605 <Button
executor/apps/web/src/lib/api.ts+1−1
@@ -143,7 +143,7 @@ export async function upsertToolSource(request: { · 142 unmodified lines
143143 id?: string;
144144 workspaceId: string;
145145 name: string;
146− type: "mcp" | "openapi";
146+ type: "mcp" | "openapi" | "graphql";
147147 config: Record<string, unknown>;
148148 enabled?: boolean;
149149 }): Promise<ToolSourceRecord> {
executor/apps/web/src/lib/types.ts+3−1
@@ -120,11 +120,13 @@ export interface CredentialDescriptor { · 119 unmodified lines
120120 hasSecret: boolean;
121121 }
122122
123+export type ToolSourceType = "mcp" | "openapi" | "graphql";
124+
123125 export interface ToolSourceRecord {
124126 id: string;
125127 workspaceId: string;
126128 name: string;
127− type: "mcp" | "openapi";
129+ type: ToolSourceType;
128130 config: Record<string, unknown>;
129131 enabled: boolean;
130132 createdAt: number;
executor/packages/contracts/src/index.ts+3−1
@@ -116,11 +116,13 @@ export interface CredentialDescriptor { · 115 unmodified lines
116116 hasSecret: boolean;
117117 }
118118
119+export type ToolSourceType = "mcp" | "openapi" | "graphql";
120+
119121 export interface ToolSourceRecord {
120122 id: string;
121123 workspaceId: string;
122124 name: string;
123− type: "mcp" | "openapi";
125+ type: ToolSourceType;
124126 config: Record<string, unknown>;
125127 enabled: boolean;
126128 createdAt: number;