- 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 | 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 { |
| @@ -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 { |
| @@ -4,7 +4,7 @@ import { InProcessExecutionAdapter } from "./adapters/in-process-execution-adapt · 3 unmodified lines | |||
| 4 | import { APPROVAL_DENIED_PREFIX } from "./execution-constants"; | 4 | import { APPROVAL_DENIED_PREFIX } from "./execution-constants"; |
| 5 | import { createDiscoverTool } from "./tool-discovery"; | 5 | import { createDiscoverTool } from "./tool-discovery"; |
| 6 | import type { ExternalToolSourceConfig } from "./tool-sources"; | 6 | import type { ExternalToolSourceConfig } from "./tool-sources"; |
| 7 | import { loadExternalTools } from "./tool-sources"; | 7 | import { loadExternalTools, parseGraphqlOperationPaths } from "./tool-sources"; |
| 8 | import type { | 8 | import 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 | ||
| 77 | function normalizeExternalToolSource(raw: { | 78 | function 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; |
| @@ -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 | ||
| 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 | function toObject(value: unknown): Record<string, unknown> { | 52 | function 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 | |||
| 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 | export function parseToolSourcesFromEnv(raw: string | undefined): ExternalToolSourceConfig[] { | 801 | export 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 | ||
| 414 | export async function loadExternalTools(sources: ExternalToolSourceConfig[]): Promise<ToolDefinition[]> { | 814 | export 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); |
| @@ -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 | ||
| 207 | export type ToolSourceType = "mcp" | "openapi" | "graphql"; | ||
| 208 | |||
| 203 | export interface ToolSourceRecord { | 209 | export 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; |
| @@ -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 | ||
| 197 | function getFaviconUrl(preset: ApiPreset): string | null { | 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 | function getSourceFavicon(source: ToolSourceRecord): string | null { | 210 | function 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 Spec | 399 | 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 | <Input | 411 | <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 | <Button | 605 | <Button |
| @@ -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> { |
| @@ -120,11 +120,13 @@ export interface CredentialDescriptor { · 119 unmodified lines | |||
| 120 | hasSecret: boolean; | 120 | hasSecret: boolean; |
| 121 | } | 121 | } |
| 122 | 122 | ||
| 123 | export type ToolSourceType = "mcp" | "openapi" | "graphql"; | ||
| 124 | |||
| 123 | export interface ToolSourceRecord { | 125 | export 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; |
| @@ -116,11 +116,13 @@ export interface CredentialDescriptor { · 115 unmodified lines | |||
| 116 | hasSecret: boolean; | 116 | hasSecret: boolean; |
| 117 | } | 117 | } |
| 118 | 118 | ||
| 119 | export type ToolSourceType = "mcp" | "openapi" | "graphql"; | ||
| 120 | |||
| 119 | export interface ToolSourceRecord { | 121 | export 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; |