- 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.
Unified · Split
| @@ -106,7 +106,7 @@ function mapToolSourceRow(row: Record<string, unknown>): ToolSourceRecord { · 105 unmodified lines | ||
| 106 | 106 | id: String(row.id), |
| 107 | 107 | workspaceId: String(row.workspace_id), |
| 108 | 108 | name: String(row.name), |
| 109 | − type: String(row.type) as "mcp" | "openapi", | |
| 109 | + type: String(row.type) as ToolSourceRecord["type"], | |
| 110 | 110 | config: parseMetadata(row.config_json), |
| 111 | 111 | enabled: Number(row.enabled) === 1, |
| 112 | 112 | createdAt: Number(row.created_at), |
| @@ -700,7 +700,7 @@ export class ExecutorDatabase { · 587 unmodified lines | ||
| 700 | 700 | id?: string; |
| 701 | 701 | workspaceId: string; |
| 702 | 702 | name: string; |
| 703 | − type: "mcp" | "openapi"; | |
| 703 | + type: ToolSourceRecord["type"]; | |
| 704 | 704 | config: Record<string, unknown>; |
| 705 | 705 | enabled?: boolean; |
| 706 | 706 | }): ToolSourceRecord { |
| @@ -213,7 +213,7 @@ const server = Bun.serve({ · 212 unmodified lines | ||
| 213 | 213 | id?: string; |
| 214 | 214 | workspaceId?: string; |
| 215 | 215 | name?: string; |
| 216 | − type?: "mcp" | "openapi"; | |
| 216 | + type?: "mcp" | "openapi" | "graphql"; | |
| 217 | 217 | config?: Record<string, unknown>; |
| 218 | 218 | enabled?: boolean; |
| 219 | 219 | }>(request); |
| @@ -222,8 +222,8 @@ const server = Bun.serve({ · 2 unmodified lines | ||
| 222 | 222 | return error(400, "workspaceId, name, type, and config are required"); |
| 223 | 223 | } |
| 224 | 224 | |
| 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'"); | |
| 227 | 227 | } |
| 228 | 228 | |
| 229 | 229 | try { |
| @@ -4,7 +4,7 @@ import { InProcessExecutionAdapter } from "./adapters/in-process-execution-adapt · 3 unmodified lines | ||
| 4 | 4 | import { APPROVAL_DENIED_PREFIX } from "./execution-constants"; |
| 5 | 5 | import { createDiscoverTool } from "./tool-discovery"; |
| 6 | 6 | import type { ExternalToolSourceConfig } from "./tool-sources"; |
| 7 | −import { loadExternalTools } from "./tool-sources"; | |
| 7 | +import { loadExternalTools, parseGraphqlOperationPaths } from "./tool-sources"; | |
| 8 | 8 | import type { |
| 9 | 9 | AccessPolicyRecord, |
| 10 | 10 | AnonymousContext, |
| @@ -22,6 +22,7 @@ import type { · 11 unmodified lines | ||
| 22 | 22 | ToolCallRequest, |
| 23 | 23 | ToolDefinition, |
| 24 | 24 | ToolDescriptor, |
| 25 | + ToolSourceRecord, | |
| 25 | 26 | RuntimeOutputEvent, |
| 26 | 27 | PolicyDecision, |
| 27 | 28 | ResolvedToolCredential, |
| @@ -75,7 +76,7 @@ function sourceSignature(workspaceId: string, sources: Array<{ id: string; updat · 47 unmodified lines | ||
| 75 | 76 | } |
| 76 | 77 | |
| 77 | 78 | function normalizeExternalToolSource(raw: { |
| 78 | − type: "mcp" | "openapi"; | |
| 79 | + type: ToolSourceRecord["type"]; | |
| 79 | 80 | name: string; |
| 80 | 81 | config: Record<string, unknown>; |
| 81 | 82 | }): ExternalToolSourceConfig { |
| @@ -92,6 +93,13 @@ function normalizeExternalToolSource(raw: { · 10 unmodified lines | ||
| 92 | 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 | 103 | const spec = merged.spec; |
| 96 | 104 | if (typeof spec !== "string" && typeof spec !== "object") { |
| 97 | 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<{ | |
| 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[] { | |
| 205 | 204 | return this.db.listToolSources(workspaceId); |
| 206 | 205 | } |
| 207 | 206 | |
| @@ -209,19 +208,10 @@ export class ExecutorService { · 1 unmodified line | ||
| 209 | 208 | id?: string; |
| 210 | 209 | workspaceId: string; |
| 211 | 210 | name: string; |
| 212 | − type: "mcp" | "openapi"; | |
| 211 | + type: ToolSourceRecord["type"]; | |
| 213 | 212 | config: Record<string, unknown>; |
| 214 | 213 | 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[] }> { | |
| 225 | 215 | const source = this.db.upsertToolSource(input); |
| 226 | 216 | this.workspaceToolCache.delete(source.workspaceId); |
| 227 | 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 | 653 | private async invokeTool(task: TaskRecord, call: ToolCallRequest): Promise<unknown> { |
| 601 | 654 | const { toolPath, input, callId } = call; |
| 602 | 655 | const workspaceTools = await this.getWorkspaceTools(task.workspaceId); |
| @@ -605,15 +658,29 @@ export class ExecutorService { · 2 unmodified lines | ||
| 605 | 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 | 676 | if (decision === "deny") { |
| 610 | 677 | this.publish(task.id, "task", "tool.call.denied", { |
| 611 | 678 | taskId: task.id, |
| 612 | 679 | callId, |
| 613 | − toolPath, | |
| 680 | + toolPath: effectiveToolPath, | |
| 614 | 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 | 686 | let credential: ResolvedToolCredential | undefined; |
| @@ -630,7 +697,7 @@ export class ExecutorService { · 10 unmodified lines | ||
| 630 | 697 | this.publish(task.id, "task", "tool.call.started", { |
| 631 | 698 | taskId: task.id, |
| 632 | 699 | callId, |
| 633 | − toolPath, | |
| 700 | + toolPath: effectiveToolPath, | |
| 634 | 701 | approval: decision === "require_approval" ? "required" : "auto", |
| 635 | 702 | input: asPayload(input), |
| 636 | 703 | }); |
| @@ -639,7 +706,7 @@ export class ExecutorService { · 2 unmodified lines | ||
| 639 | 706 | const approval = this.db.createApproval({ |
| 640 | 707 | id: createApprovalId(), |
| 641 | 708 | taskId: task.id, |
| 642 | − toolPath, | |
| 709 | + toolPath: effectiveToolPath, | |
| 643 | 710 | input, |
| 644 | 711 | }); |
| 645 | 712 | |
| @@ -652,15 +719,15 @@ export class ExecutorService { · 6 unmodified lines | ||
| 652 | 719 | createdAt: approval.createdAt, |
| 653 | 720 | }); |
| 654 | 721 | |
| 655 | − const decision = await this.waitForApproval(approval.id); | |
| 656 | − if (decision === "denied") { | |
| 722 | + const approvalDecision = await this.waitForApproval(approval.id); | |
| 723 | + if (approvalDecision === "denied") { | |
| 657 | 724 | this.publish(task.id, "task", "tool.call.denied", { |
| 658 | 725 | taskId: task.id, |
| 659 | 726 | callId, |
| 660 | − toolPath, | |
| 727 | + toolPath: effectiveToolPath, | |
| 661 | 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 | 744 | this.publish(task.id, "task", "tool.call.completed", { |
| 678 | 745 | taskId: task.id, |
| 679 | 746 | callId, |
| 680 | − toolPath, | |
| 747 | + toolPath: effectiveToolPath, | |
| 681 | 748 | output: asPayload(value), |
| 682 | 749 | }); |
| 683 | 750 | return value; |
| @@ -686,7 +753,7 @@ export class ExecutorService { · 2 unmodified lines | ||
| 686 | 753 | this.publish(task.id, "task", "tool.call.failed", { |
| 687 | 754 | taskId: task.id, |
| 688 | 755 | callId, |
| 689 | − toolPath, | |
| 756 | + toolPath: effectiveToolPath, | |
| 690 | 757 | error: message, |
| 691 | 758 | }); |
| 692 | 759 | throw error; |
| @@ -32,7 +32,22 @@ export interface OpenApiToolSourceConfig { · 31 unmodified lines | ||
| 32 | 32 | overrides?: Record<string, { approval?: ToolApprovalMode }>; |
| 33 | 33 | } |
| 34 | 34 | |
| 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; | |
| 36 | 51 | |
| 37 | 52 | function toObject(value: unknown): Record<string, unknown> { |
| 38 | 53 | return value && typeof value === "object" && !Array.isArray(value) |
| @@ -398,6 +413,391 @@ async function loadOpenApiTools(config: OpenApiToolSourceConfig): Promise<ToolDe · 359 unmodified lines | ||
| 398 | 413 | return tools; |
| 399 | 414 | } |
| 400 | 415 | |
| 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 | + | |
| 401 | 801 | export function parseToolSourcesFromEnv(raw: string | undefined): ExternalToolSourceConfig[] { |
| 402 | 802 | if (!raw || raw.trim().length === 0) { |
| 403 | 803 | return []; |
| @@ -411,7 +811,7 @@ export function parseToolSourcesFromEnv(raw: string | undefined): ExternalToolSo · 7 unmodified lines | ||
| 411 | 811 | return parsed as ExternalToolSourceConfig[]; |
| 412 | 812 | } |
| 413 | 813 | |
| 414 | −export async function loadExternalTools(sources: ExternalToolSourceConfig[]): Promise<ToolDefinition[]> { | |
| 814 | +export async function loadExternalTools(sources: ExternalToolSourceConfig[]): Promise<{ tools: ToolDefinition[]; warnings: string[] }> { | |
| 415 | 815 | const loaded: ToolDefinition[] = []; |
| 416 | 816 | const warnings: string[] = []; |
| 417 | 817 | |
| @@ -421,6 +821,8 @@ export async function loadExternalTools(sources: ExternalToolSourceConfig[]): Pr · 3 unmodified lines | ||
| 421 | 821 | loaded.push(...(await loadMcpTools(source))); |
| 422 | 822 | } else if (source.type === "openapi") { |
| 423 | 823 | loaded.push(...(await loadOpenApiTools(source))); |
| 824 | + } else if (source.type === "graphql") { | |
| 825 | + loaded.push(...(await loadGraphqlTools(source))); | |
| 424 | 826 | } |
| 425 | 827 | } catch (error) { |
| 426 | 828 | const message = error instanceof Error ? error.message : String(error); |
| @@ -188,6 +188,10 @@ export interface ToolDefinition { · 187 unmodified lines | ||
| 188 | 188 | source?: string; |
| 189 | 189 | metadata?: ToolTypeMetadata; |
| 190 | 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 | 195 | run(input: unknown, context: ToolRunContext): Promise<unknown>; |
| 192 | 196 | } |
| 193 | 197 | |
| @@ -200,11 +204,13 @@ export interface ToolDescriptor { · 6 unmodified lines | ||
| 200 | 204 | returnsType?: string; |
| 201 | 205 | } |
| 202 | 206 | |
| 207 | +export type ToolSourceType = "mcp" | "openapi" | "graphql"; | |
| 208 | + | |
| 203 | 209 | export interface ToolSourceRecord { |
| 204 | 210 | id: string; |
| 205 | 211 | workspaceId: string; |
| 206 | 212 | name: string; |
| 207 | − type: "mcp" | "openapi"; | |
| 213 | + type: ToolSourceType; | |
| 208 | 214 | config: Record<string, unknown>; |
| 209 | 215 | enabled: boolean; |
| 210 | 216 | createdAt: number; |
| @@ -55,9 +55,10 @@ interface ApiPreset { · 54 unmodified lines | ||
| 55 | 55 | name: string; |
| 56 | 56 | label: string; |
| 57 | 57 | description: string; |
| 58 | − type: "openapi" | "mcp"; | |
| 58 | + type: "openapi" | "mcp" | "graphql"; | |
| 59 | 59 | spec?: string; |
| 60 | 60 | url?: string; |
| 61 | + endpoint?: string; | |
| 61 | 62 | baseUrl?: string; |
| 62 | 63 | authNote?: string; |
| 63 | 64 | } |
| @@ -181,6 +182,14 @@ const API_PRESETS: ApiPreset[] = [ · 117 unmodified lines | ||
| 181 | 182 | spec: "https://raw.githubusercontent.com/resend/resend-openapi/main/resend.yaml", |
| 182 | 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 | 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 | |
| 197 | 206 | function 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 | |
| 201 | 210 | function getSourceFavicon(source: ToolSourceRecord): string | null { |
| 202 | 211 | const url = |
| 203 | 212 | source.type === "mcp" |
| 204 | 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 | 217 | return faviconForUrl(url); |
| 207 | 218 | } |
| 208 | 219 | |
| @@ -249,7 +260,7 @@ function AddSourceDialog({ · 40 unmodified lines | ||
| 249 | 260 | const { context } = useSession(); |
| 250 | 261 | const [open, setOpen] = useState(false); |
| 251 | 262 | const [presetsOpen, setPresetsOpen] = useState(false); |
| 252 | − const [type, setType] = useState<"mcp" | "openapi">("mcp"); | |
| 263 | + const [type, setType] = useState<"mcp" | "openapi" | "graphql">("mcp"); | |
| 253 | 264 | const [name, setName] = useState(""); |
| 254 | 265 | const [nameManuallyEdited, setNameManuallyEdited] = useState(false); |
| 255 | 266 | const [endpoint, setEndpoint] = useState(""); |
| @@ -286,7 +297,7 @@ function AddSourceDialog({ · 30 unmodified lines | ||
| 286 | 297 | |
| 287 | 298 | const addSource = async ( |
| 288 | 299 | sourceName: string, |
| 289 | − sourceType: "mcp" | "openapi", | |
| 300 | + sourceType: "mcp" | "openapi" | "graphql", | |
| 290 | 301 | config: Record<string, unknown>, |
| 291 | 302 | ) => { |
| 292 | 303 | if (!context) return; |
| @@ -314,10 +325,12 @@ function AddSourceDialog({ · 21 unmodified lines | ||
| 314 | 325 | const config: Record<string, unknown> = |
| 315 | 326 | preset.type === "mcp" |
| 316 | 327 | ? { 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 | + }; | |
| 321 | 334 | await addSource(preset.name, preset.type, config); |
| 322 | 335 | if (preset.authNote) { |
| 323 | 336 | toast.info(preset.authNote, { duration: 6000 }); |
| @@ -337,7 +350,9 @@ function AddSourceDialog({ · 13 unmodified lines | ||
| 337 | 350 | const config: Record<string, unknown> = |
| 338 | 351 | type === "mcp" |
| 339 | 352 | ? { url: endpoint } |
| 340 | − : { spec: endpoint, ...(baseUrl ? { baseUrl } : {}) }; | |
| 353 | + : type === "graphql" | |
| 354 | + ? { endpoint: endpoint } | |
| 355 | + : { spec: endpoint, ...(baseUrl ? { baseUrl } : {}) }; | |
| 341 | 356 | await addSource(name.trim(), type, config); |
| 342 | 357 | resetForm(); |
| 343 | 358 | setOpen(false); |
| @@ -383,12 +398,15 @@ function AddSourceDialog({ · 39 unmodified lines | ||
| 383 | 398 | <SelectItem value="openapi" className="text-xs"> |
| 384 | 399 | OpenAPI Spec |
| 385 | 400 | </SelectItem> |
| 401 | + <SelectItem value="graphql" className="text-xs"> | |
| 402 | + GraphQL | |
| 403 | + </SelectItem> | |
| 386 | 404 | </SelectContent> |
| 387 | 405 | </Select> |
| 388 | 406 | </div> |
| 389 | 407 | <div className="space-y-1.5"> |
| 390 | 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 | 410 | </Label> |
| 393 | 411 | <Input |
| 394 | 412 | value={endpoint} |
| @@ -396,7 +414,9 @@ function AddSourceDialog({ · 1 unmodified line | ||
| 396 | 414 | placeholder={ |
| 397 | 415 | type === "mcp" |
| 398 | 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 | 421 | className="h-8 text-xs font-mono bg-background" |
| 402 | 422 | /> |
| @@ -577,7 +597,9 @@ function SourceCard({ · 174 unmodified lines | ||
| 577 | 597 | <span className="text-[11px] text-muted-foreground font-mono truncate block"> |
| 578 | 598 | {source.type === "mcp" |
| 579 | 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 | 603 | </span> |
| 582 | 604 | </div> |
| 583 | 605 | <Button |
| @@ -143,7 +143,7 @@ export async function upsertToolSource(request: { · 142 unmodified lines | ||
| 143 | 143 | id?: string; |
| 144 | 144 | workspaceId: string; |
| 145 | 145 | name: string; |
| 146 | − type: "mcp" | "openapi"; | |
| 146 | + type: "mcp" | "openapi" | "graphql"; | |
| 147 | 147 | config: Record<string, unknown>; |
| 148 | 148 | enabled?: boolean; |
| 149 | 149 | }): Promise<ToolSourceRecord> { |
| @@ -120,11 +120,13 @@ export interface CredentialDescriptor { · 119 unmodified lines | ||
| 120 | 120 | hasSecret: boolean; |
| 121 | 121 | } |
| 122 | 122 | |
| 123 | +export type ToolSourceType = "mcp" | "openapi" | "graphql"; | |
| 124 | + | |
| 123 | 125 | export interface ToolSourceRecord { |
| 124 | 126 | id: string; |
| 125 | 127 | workspaceId: string; |
| 126 | 128 | name: string; |
| 127 | − type: "mcp" | "openapi"; | |
| 129 | + type: ToolSourceType; | |
| 128 | 130 | config: Record<string, unknown>; |
| 129 | 131 | enabled: boolean; |
| 130 | 132 | createdAt: number; |
| @@ -116,11 +116,13 @@ export interface CredentialDescriptor { · 115 unmodified lines | ||
| 116 | 116 | hasSecret: boolean; |
| 117 | 117 | } |
| 118 | 118 | |
| 119 | +export type ToolSourceType = "mcp" | "openapi" | "graphql"; | |
| 120 | + | |
| 119 | 121 | export interface ToolSourceRecord { |
| 120 | 122 | id: string; |
| 121 | 123 | workspaceId: string; |
| 122 | 124 | name: string; |
| 123 | − type: "mcp" | "openapi"; | |
| 125 | + type: ToolSourceType; | |
| 124 | 126 | config: Record<string, unknown>; |
| 125 | 127 | enabled: boolean; |
| 126 | 128 | createdAt: number; |