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
106 id: String(row.id),106 id: String(row.id),
107 workspaceId: String(row.workspace_id),107 workspaceId: String(row.workspace_id),
108 name: String(row.name),108 name: String(row.name),
109 type: String(row.type) as "mcp" | "openapi",109 type: String(row.type) as ToolSourceRecord["type"],
110 config: parseMetadata(row.config_json),110 config: parseMetadata(row.config_json),
111 enabled: Number(row.enabled) === 1,111 enabled: Number(row.enabled) === 1,
112 createdAt: Number(row.created_at),112 createdAt: Number(row.created_at),
@@ -700,7 +700,7 @@ export class ExecutorDatabase { · 587 unmodified lines
700 id?: string;700 id?: string;
701 workspaceId: string;701 workspaceId: string;
702 name: string;702 name: string;
703 type: "mcp" | "openapi";703 type: ToolSourceRecord["type"];
704 config: Record<string, unknown>;704 config: Record<string, unknown>;
705 enabled?: boolean;705 enabled?: boolean;
706 }): ToolSourceRecord {706 }): ToolSourceRecord {
executor/apps/server/src/index.ts+3−3
@@ -213,7 +213,7 @@ const server = Bun.serve({ · 212 unmodified lines
213 id?: string;213 id?: string;
214 workspaceId?: string;214 workspaceId?: string;
215 name?: string;215 name?: string;
216 type?: "mcp" | "openapi";216 type?: "mcp" | "openapi" | "graphql";
217 config?: Record<string, unknown>;217 config?: Record<string, unknown>;
218 enabled?: boolean;218 enabled?: boolean;
219 }>(request);219 }>(request);
@@ -222,8 +222,8 @@ const server = Bun.serve({ · 2 unmodified lines
222 return error(400, "workspaceId, name, type, and config are required");222 return error(400, "workspaceId, name, type, and config are required");
223 }223 }
224 224
225 if (body.type !== "mcp" && body.type !== "openapi") {225 if (body.type !== "mcp" && body.type !== "openapi" && body.type !== "graphql") {
226 return error(400, "type must be 'mcp' or 'openapi'");226 return error(400, "type must be 'mcp', 'openapi', or 'graphql'");
227 }227 }
228 228
229 try {229 try {
executor/apps/server/src/service.ts+101−34
@@ -4,7 +4,7 @@ import { InProcessExecutionAdapter } from "./adapters/in-process-execution-adapt · 3 unmodified lines
4import { APPROVAL_DENIED_PREFIX } from "./execution-constants";4import { APPROVAL_DENIED_PREFIX } from "./execution-constants";
5import { createDiscoverTool } from "./tool-discovery";5import { createDiscoverTool } from "./tool-discovery";
6import type { ExternalToolSourceConfig } from "./tool-sources";6import type { ExternalToolSourceConfig } from "./tool-sources";
7import { loadExternalTools } from "./tool-sources";7import { loadExternalTools, parseGraphqlOperationPaths } from "./tool-sources";
8import type {8import type {
9 AccessPolicyRecord,9 AccessPolicyRecord,
10 AnonymousContext,10 AnonymousContext,
@@ -22,6 +22,7 @@ import type { · 11 unmodified lines
22 ToolCallRequest,22 ToolCallRequest,
23 ToolDefinition,23 ToolDefinition,
24 ToolDescriptor,24 ToolDescriptor,
25 ToolSourceRecord,
25 RuntimeOutputEvent,26 RuntimeOutputEvent,
26 PolicyDecision,27 PolicyDecision,
27 ResolvedToolCredential,28 ResolvedToolCredential,
@@ -75,7 +76,7 @@ function sourceSignature(workspaceId: string, sources: Array<{ id: string; updat · 47 unmodified lines
75}76}
76 77
77function normalizeExternalToolSource(raw: {78function normalizeExternalToolSource(raw: {
78 type: "mcp" | "openapi";79 type: ToolSourceRecord["type"];
79 name: string;80 name: string;
80 config: Record<string, unknown>;81 config: Record<string, unknown>;
81}): ExternalToolSourceConfig {82}): ExternalToolSourceConfig {
@@ -92,6 +93,13 @@ function normalizeExternalToolSource(raw: { · 10 unmodified lines
92 return merged as unknown as ExternalToolSourceConfig;93 return merged as unknown as ExternalToolSourceConfig;
93 }94 }
94 95
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
95 const spec = merged.spec;103 const spec = merged.spec;
96 if (typeof spec !== "string" && typeof spec !== "object") {104 if (typeof spec !== "string" && typeof spec !== "object") {
97 throw new Error(`OpenAPI source '${raw.name}' missing spec`);105 throw new Error(`OpenAPI source '${raw.name}' missing spec`);
@@ -192,16 +200,7 @@ export class ExecutorService { · 94 unmodified lines
192 }));200 }));
193 }201 }
194 202
195 listToolSources(workspaceId: string): Array<{203 listToolSources(workspaceId: string): ToolSourceRecord[] {
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 }> {
205 return this.db.listToolSources(workspaceId);204 return this.db.listToolSources(workspaceId);
206 }205 }
207 206
@@ -209,19 +208,10 @@ export class ExecutorService { · 1 unmodified line
209 id?: string;208 id?: string;
210 workspaceId: string;209 workspaceId: string;
211 name: string;210 name: string;
212 type: "mcp" | "openapi";211 type: ToolSourceRecord["type"];
213 config: Record<string, unknown>;212 config: Record<string, unknown>;
214 enabled?: boolean;213 enabled?: boolean;
215 }): Promise<{214 }): Promise<ToolSourceRecord & { warnings?: string[] }> {
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 }> {
225 const source = this.db.upsertToolSource(input);215 const source = this.db.upsertToolSource(input);
226 this.workspaceToolCache.delete(source.workspaceId);216 this.workspaceToolCache.delete(source.workspaceId);
227 await this.getWorkspaceTools(source.workspaceId);217 await this.getWorkspaceTools(source.workspaceId);
@@ -597,6 +587,69 @@ export class ExecutorService { · 369 unmodified lines
597 };587 };
598 }588 }
599 589
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
600 private async invokeTool(task: TaskRecord, call: ToolCallRequest): Promise<unknown> {653 private async invokeTool(task: TaskRecord, call: ToolCallRequest): Promise<unknown> {
601 const { toolPath, input, callId } = call;654 const { toolPath, input, callId } = call;
602 const workspaceTools = await this.getWorkspaceTools(task.workspaceId);655 const workspaceTools = await this.getWorkspaceTools(task.workspaceId);
@@ -605,15 +658,29 @@ export class ExecutorService { · 2 unmodified lines
605 throw new Error(`Unknown tool: ${toolPath}`);658 throw new Error(`Unknown tool: ${toolPath}`);
606 }659 }
607 660
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
609 if (decision === "deny") {676 if (decision === "deny") {
610 this.publish(task.id, "task", "tool.call.denied", {677 this.publish(task.id, "task", "tool.call.denied", {
611 taskId: task.id,678 taskId: task.id,
612 callId,679 callId,
613 toolPath,680 toolPath: effectiveToolPath,
614 reason: "policy_deny",681 reason: "policy_deny",
615 });682 });
616 throw new Error(`${APPROVAL_DENIED_PREFIX}${toolPath} (policy denied)`);683 throw new Error(`${APPROVAL_DENIED_PREFIX}${effectiveToolPath} (policy denied)`);
617 }684 }
618 685
619 let credential: ResolvedToolCredential | undefined;686 let credential: ResolvedToolCredential | undefined;
@@ -630,7 +697,7 @@ export class ExecutorService { · 10 unmodified lines
630 this.publish(task.id, "task", "tool.call.started", {697 this.publish(task.id, "task", "tool.call.started", {
631 taskId: task.id,698 taskId: task.id,
632 callId,699 callId,
633 toolPath,700 toolPath: effectiveToolPath,
634 approval: decision === "require_approval" ? "required" : "auto",701 approval: decision === "require_approval" ? "required" : "auto",
635 input: asPayload(input),702 input: asPayload(input),
636 });703 });
@@ -639,7 +706,7 @@ export class ExecutorService { · 2 unmodified lines
639 const approval = this.db.createApproval({706 const approval = this.db.createApproval({
640 id: createApprovalId(),707 id: createApprovalId(),
641 taskId: task.id,708 taskId: task.id,
642 toolPath,709 toolPath: effectiveToolPath,
643 input,710 input,
644 });711 });
645 712
@@ -652,15 +719,15 @@ export class ExecutorService { · 6 unmodified lines
652 createdAt: approval.createdAt,719 createdAt: approval.createdAt,
653 });720 });
654 721
655 const decision = await this.waitForApproval(approval.id);722 const approvalDecision = await this.waitForApproval(approval.id);
656 if (decision === "denied") {723 if (approvalDecision === "denied") {
657 this.publish(task.id, "task", "tool.call.denied", {724 this.publish(task.id, "task", "tool.call.denied", {
658 taskId: task.id,725 taskId: task.id,
659 callId,726 callId,
660 toolPath,727 toolPath: effectiveToolPath,
661 approvalId: approval.id,728 approvalId: approval.id,
662 });729 });
663 throw new Error(`${APPROVAL_DENIED_PREFIX}${toolPath} (${approval.id})`);730 throw new Error(`${APPROVAL_DENIED_PREFIX}${effectiveToolPath} (${approval.id})`);
664 }731 }
665 }732 }
666 733
@@ -677,7 +744,7 @@ export class ExecutorService { · 10 unmodified lines
677 this.publish(task.id, "task", "tool.call.completed", {744 this.publish(task.id, "task", "tool.call.completed", {
678 taskId: task.id,745 taskId: task.id,
679 callId,746 callId,
680 toolPath,747 toolPath: effectiveToolPath,
681 output: asPayload(value),748 output: asPayload(value),
682 });749 });
683 return value;750 return value;
@@ -686,7 +753,7 @@ export class ExecutorService { · 2 unmodified lines
686 this.publish(task.id, "task", "tool.call.failed", {753 this.publish(task.id, "task", "tool.call.failed", {
687 taskId: task.id,754 taskId: task.id,
688 callId,755 callId,
689 toolPath,756 toolPath: effectiveToolPath,
690 error: message,757 error: message,
691 });758 });
692 throw error;759 throw error;
executor/apps/server/src/tool-sources.ts+404−2
@@ -32,7 +32,22 @@ export interface OpenApiToolSourceConfig { · 31 unmodified lines
32 overrides?: Record<string, { approval?: ToolApprovalMode }>;32 overrides?: Record<string, { approval?: ToolApprovalMode }>;
33}33}
34 34
35export type ExternalToolSourceConfig = McpToolSourceConfig | OpenApiToolSourceConfig;35export 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
47export type ExternalToolSourceConfig =
48 | McpToolSourceConfig
49 | OpenApiToolSourceConfig
50 | GraphqlToolSourceConfig;
36 51
37function toObject(value: unknown): Record<string, unknown> {52function toObject(value: unknown): Record<string, unknown> {
38 return value && typeof value === "object" && !Array.isArray(value)53 return value && typeof value === "object" && !Array.isArray(value)
@@ -398,6 +413,391 @@ async function loadOpenApiTools(config: OpenApiToolSourceConfig): Promise<ToolDe · 359 unmodified lines
398 return tools;413 return tools;
399}414}
400 415
416// ── GraphQL introspection ──
417
418const 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
454interface GqlTypeRef {
455 kind: string;
456 name: string | null;
457 ofType?: GqlTypeRef | null;
458}
459
460interface 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
472interface GqlInputField {
473 name: string;
474 description: string | null;
475 type: GqlTypeRef;
476 defaultValue: string | null;
477}
478
479interface GqlEnumValue {
480 name: string;
481 description: string | null;
482}
483
484interface GqlType {
485 kind: string;
486 name: string;
487 fields: GqlField[] | null;
488 inputFields: GqlInputField[] | null;
489 enumValues: GqlEnumValue[] | null;
490}
491
492interface 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) */
499function 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 */
509function 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
551function 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
562function 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
571function 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 */
576function 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
589function 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 */
599export 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
658async 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
401export function parseToolSourcesFromEnv(raw: string | undefined): ExternalToolSourceConfig[] {801export function parseToolSourcesFromEnv(raw: string | undefined): ExternalToolSourceConfig[] {
402 if (!raw || raw.trim().length === 0) {802 if (!raw || raw.trim().length === 0) {
403 return [];803 return [];
@@ -411,7 +811,7 @@ export function parseToolSourcesFromEnv(raw: string | undefined): ExternalToolSo · 7 unmodified lines
411 return parsed as ExternalToolSourceConfig[];811 return parsed as ExternalToolSourceConfig[];
412}812}
413 813
414export async function loadExternalTools(sources: ExternalToolSourceConfig[]): Promise<ToolDefinition[]> {814export async function loadExternalTools(sources: ExternalToolSourceConfig[]): Promise<{ tools: ToolDefinition[]; warnings: string[] }> {
415 const loaded: ToolDefinition[] = [];815 const loaded: ToolDefinition[] = [];
416 const warnings: string[] = [];816 const warnings: string[] = [];
417 817
@@ -421,6 +821,8 @@ export async function loadExternalTools(sources: ExternalToolSourceConfig[]): Pr · 3 unmodified lines
421 loaded.push(...(await loadMcpTools(source)));821 loaded.push(...(await loadMcpTools(source)));
422 } else if (source.type === "openapi") {822 } else if (source.type === "openapi") {
423 loaded.push(...(await loadOpenApiTools(source)));823 loaded.push(...(await loadOpenApiTools(source)));
824 } else if (source.type === "graphql") {
825 loaded.push(...(await loadGraphqlTools(source)));
424 }826 }
425 } catch (error) {827 } catch (error) {
426 const message = error instanceof Error ? error.message : String(error);828 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
188 source?: string;188 source?: string;
189 metadata?: ToolTypeMetadata;189 metadata?: ToolTypeMetadata;
190 credential?: ToolCredentialSpec;190 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;
191 run(input: unknown, context: ToolRunContext): Promise<unknown>;195 run(input: unknown, context: ToolRunContext): Promise<unknown>;
192}196}
193 197
@@ -200,11 +204,13 @@ export interface ToolDescriptor { · 6 unmodified lines
200 returnsType?: string;204 returnsType?: string;
201}205}
202 206
207export type ToolSourceType = "mcp" | "openapi" | "graphql";
208
203export interface ToolSourceRecord {209export interface ToolSourceRecord {
204 id: string;210 id: string;
205 workspaceId: string;211 workspaceId: string;
206 name: string;212 name: string;
207 type: "mcp" | "openapi";213 type: ToolSourceType;
208 config: Record<string, unknown>;214 config: Record<string, unknown>;
209 enabled: boolean;215 enabled: boolean;
210 createdAt: number;216 createdAt: number;
executor/apps/web/src/components/tools-view.tsx+35−13
@@ -55,9 +55,10 @@ interface ApiPreset { · 54 unmodified lines
55 name: string;55 name: string;
56 label: string;56 label: string;
57 description: string;57 description: string;
58 type: "openapi" | "mcp";58 type: "openapi" | "mcp" | "graphql";
59 spec?: string;59 spec?: string;
60 url?: string;60 url?: string;
61 endpoint?: string;
61 baseUrl?: string;62 baseUrl?: string;
62 authNote?: string;63 authNote?: string;
63}64}
@@ -181,6 +182,14 @@ const API_PRESETS: ApiPreset[] = [ · 117 unmodified lines
181 spec: "https://raw.githubusercontent.com/resend/resend-openapi/main/resend.yaml",182 spec: "https://raw.githubusercontent.com/resend/resend-openapi/main/resend.yaml",
182 baseUrl: "https://api.resend.com",183 baseUrl: "https://api.resend.com",
183 },184 },
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 },
184];193];
185 194
186/** Derive a favicon URL from any URL string via Google's favicon service. */195/** 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
195}204}
196 205
197function getFaviconUrl(preset: ApiPreset): string | null {206function getFaviconUrl(preset: ApiPreset): string | null {
198 return faviconForUrl(preset.baseUrl ?? preset.url);207 return faviconForUrl(preset.baseUrl ?? preset.endpoint ?? preset.url);
199}208}
200 209
201function getSourceFavicon(source: ToolSourceRecord): string | null {210function getSourceFavicon(source: ToolSourceRecord): string | null {
202 const url =211 const url =
203 source.type === "mcp"212 source.type === "mcp"
204 ? (source.config.url as string)213 ? (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);
206 return faviconForUrl(url);217 return faviconForUrl(url);
207}218}
208 219
@@ -249,7 +260,7 @@ function AddSourceDialog({ · 40 unmodified lines
249 const { context } = useSession();260 const { context } = useSession();
250 const [open, setOpen] = useState(false);261 const [open, setOpen] = useState(false);
251 const [presetsOpen, setPresetsOpen] = useState(false);262 const [presetsOpen, setPresetsOpen] = useState(false);
252 const [type, setType] = useState<"mcp" | "openapi">("mcp");263 const [type, setType] = useState<"mcp" | "openapi" | "graphql">("mcp");
253 const [name, setName] = useState("");264 const [name, setName] = useState("");
254 const [nameManuallyEdited, setNameManuallyEdited] = useState(false);265 const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
255 const [endpoint, setEndpoint] = useState("");266 const [endpoint, setEndpoint] = useState("");
@@ -286,7 +297,7 @@ function AddSourceDialog({ · 30 unmodified lines
286 297
287 const addSource = async (298 const addSource = async (
288 sourceName: string,299 sourceName: string,
289 sourceType: "mcp" | "openapi",300 sourceType: "mcp" | "openapi" | "graphql",
290 config: Record<string, unknown>,301 config: Record<string, unknown>,
291 ) => {302 ) => {
292 if (!context) return;303 if (!context) return;
@@ -314,10 +325,12 @@ function AddSourceDialog({ · 21 unmodified lines
314 const config: Record<string, unknown> =325 const config: Record<string, unknown> =
315 preset.type === "mcp"326 preset.type === "mcp"
316 ? { url: preset.url }327 ? { url: preset.url }
317 : {328 : preset.type === "graphql"
318 spec: preset.spec,329 ? { endpoint: preset.endpoint }
319 ...(preset.baseUrl ? { baseUrl: preset.baseUrl } : {}),330 : {
320 };331 spec: preset.spec,
332 ...(preset.baseUrl ? { baseUrl: preset.baseUrl } : {}),
333 };
321 await addSource(preset.name, preset.type, config);334 await addSource(preset.name, preset.type, config);
322 if (preset.authNote) {335 if (preset.authNote) {
323 toast.info(preset.authNote, { duration: 6000 });336 toast.info(preset.authNote, { duration: 6000 });
@@ -337,7 +350,9 @@ function AddSourceDialog({ · 13 unmodified lines
337 const config: Record<string, unknown> =350 const config: Record<string, unknown> =
338 type === "mcp"351 type === "mcp"
339 ? { url: endpoint }352 ? { url: endpoint }
340 : { spec: endpoint, ...(baseUrl ? { baseUrl } : {}) };353 : type === "graphql"
354 ? { endpoint: endpoint }
355 : { spec: endpoint, ...(baseUrl ? { baseUrl } : {}) };
341 await addSource(name.trim(), type, config);356 await addSource(name.trim(), type, config);
342 resetForm();357 resetForm();
343 setOpen(false);358 setOpen(false);
@@ -383,12 +398,15 @@ function AddSourceDialog({ · 39 unmodified lines
383 <SelectItem value="openapi" className="text-xs">398 <SelectItem value="openapi" className="text-xs">
384 OpenAPI Spec399 OpenAPI Spec
385 </SelectItem>400 </SelectItem>
401 <SelectItem value="graphql" className="text-xs">
402 GraphQL
403 </SelectItem>
386 </SelectContent>404 </SelectContent>
387 </Select>405 </Select>
388 </div>406 </div>
389 <div className="space-y-1.5">407 <div className="space-y-1.5">
390 <Label className="text-xs text-muted-foreground">408 <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"}
392 </Label>410 </Label>
393 <Input411 <Input
394 value={endpoint}412 value={endpoint}
@@ -396,7 +414,9 @@ function AddSourceDialog({ · 1 unmodified line
396 placeholder={414 placeholder={
397 type === "mcp"415 type === "mcp"
398 ? "https://mcp-server.example.com/sse"416 ? "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"
400 }420 }
401 className="h-8 text-xs font-mono bg-background"421 className="h-8 text-xs font-mono bg-background"
402 />422 />
@@ -577,7 +597,9 @@ function SourceCard({ · 174 unmodified lines
577 <span className="text-[11px] text-muted-foreground font-mono truncate block">597 <span className="text-[11px] text-muted-foreground font-mono truncate block">
578 {source.type === "mcp"598 {source.type === "mcp"
579 ? (source.config.url as string)599 ? (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)}
581 </span>603 </span>
582 </div>604 </div>
583 <Button605 <Button
executor/apps/web/src/lib/api.ts+1−1
@@ -143,7 +143,7 @@ export async function upsertToolSource(request: { · 142 unmodified lines
143 id?: string;143 id?: string;
144 workspaceId: string;144 workspaceId: string;
145 name: string;145 name: string;
146 type: "mcp" | "openapi";146 type: "mcp" | "openapi" | "graphql";
147 config: Record<string, unknown>;147 config: Record<string, unknown>;
148 enabled?: boolean;148 enabled?: boolean;
149}): Promise<ToolSourceRecord> {149}): Promise<ToolSourceRecord> {
executor/apps/web/src/lib/types.ts+3−1
@@ -120,11 +120,13 @@ export interface CredentialDescriptor { · 119 unmodified lines
120 hasSecret: boolean;120 hasSecret: boolean;
121}121}
122 122
123export type ToolSourceType = "mcp" | "openapi" | "graphql";
124
123export interface ToolSourceRecord {125export interface ToolSourceRecord {
124 id: string;126 id: string;
125 workspaceId: string;127 workspaceId: string;
126 name: string;128 name: string;
127 type: "mcp" | "openapi";129 type: ToolSourceType;
128 config: Record<string, unknown>;130 config: Record<string, unknown>;
129 enabled: boolean;131 enabled: boolean;
130 createdAt: number;132 createdAt: number;
executor/packages/contracts/src/index.ts+3−1
@@ -116,11 +116,13 @@ export interface CredentialDescriptor { · 115 unmodified lines
116 hasSecret: boolean;116 hasSecret: boolean;
117}117}
118 118
119export type ToolSourceType = "mcp" | "openapi" | "graphql";
120
119export interface ToolSourceRecord {121export interface ToolSourceRecord {
120 id: string;122 id: string;
121 workspaceId: string;123 workspaceId: string;
122 name: string;124 name: string;
123 type: "mcp" | "openapi";125 type: ToolSourceType;
124 config: Record<string, unknown>;126 config: Record<string, unknown>;
125 enabled: boolean;127 enabled: boolean;
126 createdAt: number;128 createdAt: number;