History

graphql support

Rhys Sullivan committed 7 months ago

Commit 1ac1c191 parent 2d88a2d36 files · +2518 −25Browse files

Unified · Split

36 files
  1. AGENTS.md +1 −0
  2. CLAUDE.md +1 −0
  3. apps/server/package.json +1 −0
  4. apps/server/src/handlers/graphql.ts +17 −0
  5. apps/server/src/main.ts +5 −1
  6. apps/server/src/services/executor.ts +9 −0
  7. apps/web/package.json +1 −0
  8. apps/web/src/pages/sources.tsx +2 −0
  9. bun.lock +42 −0
  10. packages/core/sdk/src/in-memory/tool-registry.ts +13 −5
  11. packages/core/sdk/src/index.ts +1 −1
  12. packages/core/sdk/src/schema-refs.ts +65 −17
  13. packages/plugins/graphql/package.json +50 −0
  14. packages/plugins/graphql/src/api/group.ts +61 −0
  15. packages/plugins/graphql/src/api/handlers.ts +45 −0
  16. packages/plugins/graphql/src/api/index.ts +2 −0
  17. packages/plugins/graphql/src/react/AddGraphqlSource.tsx +275 −0
  18. packages/plugins/graphql/src/react/EditGraphqlSource.tsx +12 −0
  19. packages/plugins/graphql/src/react/GraphqlSourceSummary.tsx +9 −0
  20. packages/plugins/graphql/src/react/atoms.ts +7 −0
  21. packages/plugins/graphql/src/react/client.ts +20 −0
  22. packages/plugins/graphql/src/react/index.ts +3 −0
  23. packages/plugins/graphql/src/react/source-plugin.ts +14 −0
  24. packages/plugins/graphql/src/sdk/errors.ts +25 −0
  25. packages/plugins/graphql/src/sdk/extract.test.ts +260 −0
  26. packages/plugins/graphql/src/sdk/extract.ts +270 −0
  27. packages/plugins/graphql/src/sdk/index.ts +33 −0
  28. packages/plugins/graphql/src/sdk/introspect.ts +220 −0
  29. packages/plugins/graphql/src/sdk/invoke.ts +210 −0
  30. packages/plugins/graphql/src/sdk/kv-operation-store.ts +99 −0
  31. packages/plugins/graphql/src/sdk/operation-store.ts +38 −0
  32. packages/plugins/graphql/src/sdk/plugin.test.ts +195 −0
  33. packages/plugins/graphql/src/sdk/plugin.ts +392 −0
  34. packages/plugins/graphql/src/sdk/types.ts +92 −0
  35. packages/plugins/graphql/tsconfig.json +27 −0
  36. packages/plugins/openapi/src/sdk/real-specs.test.ts +1 −1
AGENTS.md+1−0
@@ -0,0 +1 @@
1+use effect vitest and run tests via vitest not bun test
CLAUDE.md+1−0
@@ -0,0 +1 @@
1+use effect vitest and run tests via vitest not bun test
apps/server/package.json+1−0
@@ -16,6 +16,7 @@ · 15 unmodified lines
1616 "@executor/plugin-keychain": "workspace:*",
1717 "@executor/plugin-onepassword": "workspace:*",
1818 "@executor/plugin-mcp": "workspace:*",
19+ "@executor/plugin-graphql": "workspace:*",
1920 "@executor/plugin-openapi": "workspace:*",
2021 "@executor/sdk": "workspace:*",
2122 "@executor/host-mcp": "workspace:*",
apps/server/src/handlers/graphql.ts+17−0
@@ -0,0 +1,17 @@
1+import { Effect, Layer } from "effect";
2+import {
3+ GraphqlHandlers,
4+ GraphqlExtensionService,
5+} from "@executor/plugin-graphql/api";
6+import { ExecutorService } from "../services/executor";
7+
8+// Wire GraphqlExtensionService from the executor's graphql extension
9+const GraphqlExtensionLive = Layer.effect(
10+ GraphqlExtensionService,
11+ Effect.map(ExecutorService, (executor) => executor.graphql),
12+);
13+
14+export const GraphqlHandlersLive = Layer.provide(
15+ GraphqlHandlers,
16+ GraphqlExtensionLive,
17+);
apps/server/src/main.ts+5−1
@@ -11,6 +11,7 @@ import { OpenApiGroup } from "@executor/plugin-openapi/api"; · 10 unmodified lines
1111 import { McpGroup } from "@executor/plugin-mcp/api";
1212 import { GoogleDiscoveryGroup } from "@executor/plugin-google-discovery/api";
1313 import { OnePasswordGroup } from "@executor/plugin-onepassword/api";
14+import { GraphqlGroup } from "@executor/plugin-graphql/api";
1415 import { ToolsHandlers } from "./handlers/tools";
1516 import { SourcesHandlers } from "./handlers/sources";
1617 import { SecretsHandlers } from "./handlers/secrets";
@@ -18,6 +19,7 @@ import { OpenApiHandlersLive } from "./handlers/openapi"; · 1 unmodified line
1819 import { McpSourceHandlersLive } from "./handlers/mcp-source";
1920 import { GoogleDiscoveryHandlersLive } from "./handlers/google-discovery";
2021 import { OnePasswordHandlersLive } from "./handlers/onepassword";
22+import { GraphqlHandlersLive } from "./handlers/graphql";
2123 import { ExecutorService, ExecutorServiceLayer, getExecutor, type ServerExecutor } from "./services/executor";
2224 import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
2325
@@ -28,7 +30,8 @@ import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; · 4 unmodified lines
2830 const ExecutorApiWithPlugins = addGroup(OpenApiGroup)
2931 .add(McpGroup)
3032 .add(GoogleDiscoveryGroup)
31− .add(OnePasswordGroup);
33+ .add(OnePasswordGroup)
34+ .add(GraphqlGroup);
3235
3336 // ---------------------------------------------------------------------------
3437 // API Layer
@@ -43,6 +46,7 @@ const ApiBase = HttpApiBuilder.api(ExecutorApiWithPlugins).pipe( · 8 unmodified lines
4346 McpSourceHandlersLive,
4447 GoogleDiscoveryHandlersLive,
4548 OnePasswordHandlersLive,
49+ GraphqlHandlersLive,
4650 ]),
4751 );
4852
apps/server/src/services/executor.ts+9−0
@@ -12,6 +12,11 @@ import { · 11 unmodified lines
1212 makeKvBindingStore as makeKvGoogleDiscoveryBindingStore,
1313 type GoogleDiscoveryPluginExtension,
1414 } from "@executor/plugin-google-discovery";
15+import {
16+ graphqlPlugin,
17+ makeKvOperationStore as makeKvGraphqlOperationStore,
18+ type GraphqlPluginExtension,
19+} from "@executor/plugin-graphql";
1520 import { keychainPlugin } from "@executor/plugin-keychain";
1621 import { fileSecretsPlugin } from "@executor/plugin-file-secrets";
1722 import { onepasswordPlugin, type OnePasswordExtension } from "@executor/plugin-onepassword";
@@ -22,6 +27,7 @@ type ServerPlugins = readonly [ · 4 unmodified lines
2227 ExecutorPlugin<"openapi", OpenApiPluginExtension>,
2328 ExecutorPlugin<"mcp", McpPluginExtension>,
2429 ExecutorPlugin<"googleDiscovery", GoogleDiscoveryPluginExtension>,
30+ ExecutorPlugin<"graphql", GraphqlPluginExtension>,
2531 ReturnType<typeof keychainPlugin>,
2632 ReturnType<typeof fileSecretsPlugin>,
2733 ExecutorPlugin<"onepassword", OnePasswordExtension>,
@@ -78,6 +84,9 @@ const ExecutorLayer = Layer.effect( · 50 unmodified lines
7884 googleDiscoveryPlugin({
7985 bindingStore: makeKvGoogleDiscoveryBindingStore(kv, "google-discovery"),
8086 }),
87+ graphqlPlugin({
88+ operationStore: makeKvGraphqlOperationStore(kv, "graphql"),
89+ }),
8190 keychainPlugin(),
8291 fileSecretsPlugin(),
8392 onepasswordPlugin({
apps/web/package.json+1−0
@@ -7,6 +7,7 @@ · 6 unmodified lines
77 "@executor/plugin-onepassword": "workspace:*",
88 "@executor/plugin-mcp": "workspace:*",
99 "@executor/plugin-google-discovery": "workspace:*",
10+ "@executor/plugin-graphql": "workspace:*",
1011 "@executor/plugin-openapi": "workspace:*",
1112 "@executor/sdk": "workspace:*",
1213 "@executor/react": "workspace:*",
apps/web/src/pages/sources.tsx+2−0
@@ -5,6 +5,7 @@ import type { SourcePlugin } from "@executor/react"; · 4 unmodified lines
55 import { openApiSourcePlugin } from "@executor/plugin-openapi/react";
66 import { mcpSourcePlugin } from "@executor/plugin-mcp/react";
77 import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react";
8+import { graphqlSourcePlugin } from "@executor/plugin-graphql/react";
89 import { McpInstallCard } from "../components/mcp-install-card";
910
1011 // ---------------------------------------------------------------------------
@@ -15,6 +16,7 @@ const sourcePlugins: SourcePlugin[] = [ · 4 unmodified lines
1516 openApiSourcePlugin,
1617 mcpSourcePlugin,
1718 googleDiscoverySourcePlugin,
19+ graphqlSourcePlugin,
1820 ];
1921
2022 // ---------------------------------------------------------------------------
bun.lock+42−0
@@ -22,6 +22,7 @@ · 21 unmodified lines
2222 "@executor/host-mcp": "workspace:*",
2323 "@executor/plugin-file-secrets": "workspace:*",
2424 "@executor/plugin-google-discovery": "workspace:*",
25+ "@executor/plugin-graphql": "workspace:*",
2526 "@executor/plugin-keychain": "workspace:*",
2627 "@executor/plugin-mcp": "workspace:*",
2728 "@executor/plugin-onepassword": "workspace:*",
@@ -42,6 +43,7 @@ · 14 unmodified lines
4243 "version": "0.0.0",
4344 "dependencies": {
4445 "@executor/plugin-google-discovery": "workspace:*",
46+ "@executor/plugin-graphql": "workspace:*",
4547 "@executor/plugin-mcp": "workspace:*",
4648 "@executor/plugin-onepassword": "workspace:*",
4749 "@executor/plugin-openapi": "workspace:*",
@@ -251,6 +253,44 @@ · 203 unmodified lines
251253 "react",
252254 ],
253255 },
256+ "packages/plugins/graphql": {
257+ "name": "@executor/plugin-graphql",
258+ "version": "0.0.0",
259+ "dependencies": {
260+ "@effect/platform": "catalog:",
261+ "@effect/platform-node": "catalog:",
262+ "@executor/sdk": "workspace:*",
263+ "effect": "catalog:",
264+ },
265+ "devDependencies": {
266+ "@effect-atom/atom-react": "^0.5.0",
267+ "@effect/vitest": "catalog:",
268+ "@executor/api": "workspace:*",
269+ "@executor/react": "workspace:*",
270+ "@executor/ui": "workspace:*",
271+ "@types/node": "catalog:",
272+ "@types/react": "^19.1.0",
273+ "bun-types": "catalog:",
274+ "react": "^19.1.0",
275+ "vitest": "catalog:",
276+ },
277+ "peerDependencies": {
278+ "@effect-atom/atom-react": "^0.5.0",
279+ "@executor/api": "workspace:*",
280+ "@executor/react": "workspace:*",
281+ "@executor/ui": "workspace:*",
282+ "@tanstack/react-router": "^1.168.10",
283+ "react": "^19.1.0",
284+ },
285+ "optionalPeers": [
286+ "@effect-atom/atom-react",
287+ "@executor/api",
288+ "@executor/react",
289+ "@executor/ui",
290+ "@tanstack/react-router",
291+ "react",
292+ ],
293+ },
254294 "packages/plugins/keychain": {
255295 "name": "@executor/plugin-keychain",
256296 "version": "0.0.0",
@@ -595,6 +635,8 @@ · 338 unmodified lines
595635
596636 "@executor/plugin-google-discovery": ["@executor/plugin-google-discovery@workspace:packages/plugins/google-discovery"],
597637
638+ "@executor/plugin-graphql": ["@executor/plugin-graphql@workspace:packages/plugins/graphql"],
639+
598640 "@executor/plugin-keychain": ["@executor/plugin-keychain@workspace:packages/plugins/keychain"],
599641
600642 "@executor/plugin-mcp": ["@executor/plugin-mcp@workspace:packages/plugins/mcp"],
packages/core/sdk/src/in-memory/tool-registry.ts+13−5
@@ -9,7 +9,7 @@ import type { · 8 unmodified lines
99 InvokeOptions,
1010 RuntimeToolHandler,
1111 } from "../tools";
12−import { reattachDefs } from "../schema-refs";
12+import { reattachDefs, normalizeRefs } from "../schema-refs";
1313
1414 export const makeInMemoryToolRegistry = () => {
1515 const tools = new Map<string, ToolRegistration>();
@@ -83,14 +83,14 @@ export const makeInMemoryToolRegistry = () => { · 67 unmodified lines
8383 registerDefinitions: (defs: Record<string, unknown>) =>
8484 Effect.sync(() => {
8585 for (const [k, v] of Object.entries(defs)) {
86− sharedDefs.set(k, v);
86+ sharedDefs.set(k, normalizeRefs(v));
8787 }
8888 }),
8989
9090 registerRuntimeDefinitions: (defs: Record<string, unknown>) =>
9191 Effect.sync(() => {
9292 for (const [k, v] of Object.entries(defs)) {
93− runtimeDefs.set(k, v);
93+ runtimeDefs.set(k, normalizeRefs(v));
9494 }
9595 }),
9696
@@ -142,14 +142,22 @@ export const makeInMemoryToolRegistry = () => { · 45 unmodified lines
142142 register: (newTools: readonly ToolRegistration[]) =>
143143 Effect.sync(() => {
144144 for (const t of newTools) {
145− tools.set(t.id, t);
145+ tools.set(t.id, {
146+ ...t,
147+ inputSchema: normalizeRefs(t.inputSchema),
148+ outputSchema: normalizeRefs(t.outputSchema),
149+ });
146150 }
147151 }),
148152
149153 registerRuntime: (newTools: readonly ToolRegistration[]) =>
150154 Effect.sync(() => {
151155 for (const t of newTools) {
152− runtimeTools.set(t.id, t);
156+ runtimeTools.set(t.id, {
157+ ...t,
158+ inputSchema: normalizeRefs(t.inputSchema),
159+ outputSchema: normalizeRefs(t.outputSchema),
160+ });
153161 }
154162 }),
155163
packages/core/sdk/src/index.ts+1−1
@@ -80,7 +80,7 @@ export { · 79 unmodified lines
8080 } from "./plugins/in-memory-tools";
8181
8282 // Schema ref utilities
83−export { hoistDefinitions, collectRefs, reattachDefs } from "./schema-refs";
83+export { hoistDefinitions, collectRefs, reattachDefs, normalizeRefs } from "./schema-refs";
8484
8585 // Runtime tools
8686 export {
packages/core/sdk/src/schema-refs.ts+65−17
@@ -5,7 +5,60 @@ · 4 unmodified lines
55 // Used by any ToolRegistry implementation (in-memory, database-backed, etc.)
66 // ---------------------------------------------------------------------------
77
8−type JsonObj = Record<string, unknown>;
8+type Obj = Record<string, unknown>;
9+
10+/** Canonical $ref prefix used internally. */
11+const CANONICAL_PREFIX = "#/$defs/";
12+
13+/** Patterns we accept as equivalent $ref pointers into shared definitions. */
14+const REF_PATTERN = /^#\/(?:\$defs|definitions|components\/schemas)\/(.+)$/;
15+
16+/** Extract the definition name from a $ref pointer */
17+const parseRefName = (ref: string): string | undefined =>
18+ ref.match(REF_PATTERN)?.[1];
19+
20+/**
21+ * Normalize a single `$ref` string to canonical `#/$defs/<name>` form.
22+ * Returns the string unchanged if it doesn't match a known pattern.
23+ */
24+const normalizeRef = (ref: string): string => {
25+ const name = parseRefName(ref);
26+ return name ? `${CANONICAL_PREFIX}${name}` : ref;
27+};
28+
29+/**
30+ * Recursively rewrite all `$ref` pointers in a schema to canonical form.
31+ * Returns the input unchanged if no rewrites are needed.
32+ */
33+export const normalizeRefs = (node: unknown): unknown => {
34+ if (node == null || typeof node !== "object") return node;
35+ if (Array.isArray(node)) {
36+ let changed = false;
37+ const out = node.map((item) => {
38+ const n = normalizeRefs(item);
39+ if (n !== item) changed = true;
40+ return n;
41+ });
42+ return changed ? out : node;
43+ }
44+
45+ const obj = node as Obj;
46+
47+ // Fast path: $ref node — only rewrite the pointer, shallow copy rest
48+ if (typeof obj.$ref === "string") {
49+ const normalized = normalizeRef(obj.$ref);
50+ return normalized !== obj.$ref ? { ...obj, $ref: normalized } : obj;
51+ }
52+
53+ let changed = false;
54+ const result: Obj = {};
55+ for (const [k, v] of Object.entries(obj)) {
56+ const n = normalizeRefs(v);
57+ if (n !== v) changed = true;
58+ result[k] = n;
59+ }
60+ return changed ? result : obj;
61+};
962
1063 /**
1164 * Extract `$defs`, `definitions`, and `components.schemas` from a JSON Schema,
@@ -18,27 +71,27 @@ export const hoistDefinitions = ( · 6 unmodified lines
1871 if (schema == null || typeof schema !== "object") {
1972 return { stripped: schema, defs: {} };
2073 }
21− const obj = schema as JsonObj;
74+ const obj = schema as Obj;
2275 const defs: Record<string, unknown> = {};
2376
2477 // $defs (JSON Schema draft 2019+, Effect)
2578 if (obj.$defs && typeof obj.$defs === "object") {
26− for (const [k, v] of Object.entries(obj.$defs as JsonObj)) {
79+ for (const [k, v] of Object.entries(obj.$defs as Obj)) {
2780 defs[k] = v;
2881 }
2982 }
3083
3184 // definitions (JSON Schema draft-07)
3285 if (obj.definitions && typeof obj.definitions === "object") {
33− for (const [k, v] of Object.entries(obj.definitions as JsonObj)) {
86+ for (const [k, v] of Object.entries(obj.definitions as Obj)) {
3487 defs[k] = v;
3588 }
3689 }
3790
3891 // components.schemas (OpenAPI)
39− const components = obj.components as JsonObj | undefined;
92+ const components = obj.components as Obj | undefined;
4093 if (components?.schemas && typeof components.schemas === "object") {
41− for (const [k, v] of Object.entries(components.schemas as JsonObj)) {
94+ for (const [k, v] of Object.entries(components.schemas as Obj)) {
4295 defs[k] = v;
4396 }
4497 }
@@ -49,7 +102,7 @@ export const hoistDefinitions = ( · 4 unmodified lines
49102 if (components && typeof components === "object") {
50103 const { schemas: _s, ...otherComponents } = components;
51104 if (Object.keys(otherComponents).length > 0) {
52− (rest as JsonObj).components = otherComponents;
105+ (rest as Obj).components = otherComponents;
53106 }
54107 }
55108
@@ -66,7 +119,7 @@ export const collectRefs = ( · 10 unmodified lines
66119 found: Set<string> = new Set(),
67120 ): Set<string> => {
68121 if (node == null || typeof node !== "object") return found;
69− const obj = node as JsonObj;
122+ const obj = node as Obj;
70123
71124 if (typeof obj.$ref === "string") {
72125 const name = parseRefName(obj.$ref);
@@ -93,6 +146,9 @@ export const collectRefs = ( · 20 unmodified lines
93146 /**
94147 * Re-attach only the referenced shared definitions into a schema,
95148 * so the caller gets a self-contained, usable JSON Schema.
149+ *
150+ * Assumes all `$ref` pointers and definitions have already been normalized
151+ * to `#/$defs/<name>` form at registration time.
96152 */
97153 export const reattachDefs = (
98154 schema: unknown,
@@ -108,13 +164,5 @@ export const reattachDefs = ( · 9 unmodified lines
108164 if (def) attached[name] = def;
109165 }
110166
111− return { ...(schema as JsonObj), $defs: attached };
112−};
113−
114−/** Extract the definition name from a $ref pointer */
115−const parseRefName = (ref: string): string | undefined => {
116− const match = ref.match(
117− /^#\/(?:\$defs|definitions|components\/schemas)\/(.+)$/,
118− );
119− return match?.[1];
167+ return { ...(schema as Record<string, unknown>), $defs: attached };
120168 };
packages/plugins/graphql/package.json+50−0
@@ -0,0 +1,50 @@
1+{
2+ "name": "@executor/plugin-graphql",
3+ "private": true,
4+ "type": "module",
5+ "version": "0.0.0",
6+ "exports": {
7+ ".": "./src/sdk/index.ts",
8+ "./api": "./src/api/index.ts",
9+ "./react": "./src/react/index.ts"
10+ },
11+ "scripts": {
12+ "typecheck": "bunx tsc --noEmit -p tsconfig.json",
13+ "test": "vitest run",
14+ "test:watch": "vitest"
15+ },
16+ "dependencies": {
17+ "@effect/platform": "catalog:",
18+ "@effect/platform-node": "catalog:",
19+ "@executor/sdk": "workspace:*",
20+ "effect": "catalog:"
21+ },
22+ "peerDependencies": {
23+ "react": "^19.1.0",
24+ "@effect-atom/atom-react": "^0.5.0",
25+ "@tanstack/react-router": "^1.168.10",
26+ "@executor/api": "workspace:*",
27+ "@executor/react": "workspace:*",
28+ "@executor/ui": "workspace:*"
29+ },
30+ "peerDependenciesMeta": {
31+ "react": { "optional": true },
32+ "@effect-atom/atom-react": { "optional": true },
33+ "@tanstack/react-router": { "optional": true },
34+ "@executor/api": { "optional": true },
35+ "@executor/react": { "optional": true },
36+ "@executor/ui": { "optional": true }
37+ },
38+ "devDependencies": {
39+ "react": "^19.1.0",
40+ "@effect-atom/atom-react": "^0.5.0",
41+ "@executor/api": "workspace:*",
42+ "@executor/react": "workspace:*",
43+ "@executor/ui": "workspace:*",
44+ "@types/react": "^19.1.0",
45+ "@effect/vitest": "catalog:",
46+ "@types/node": "catalog:",
47+ "bun-types": "catalog:",
48+ "vitest": "catalog:"
49+ }
50+}
packages/plugins/graphql/src/api/group.ts+61−0
@@ -0,0 +1,61 @@
1+import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2+import { Schema } from "effect";
3+import { ScopeId } from "@executor/sdk";
4+
5+import {
6+ GraphqlIntrospectionError,
7+ GraphqlExtractionError,
8+} from "../sdk/errors";
9+
10+// ---------------------------------------------------------------------------
11+// Params
12+// ---------------------------------------------------------------------------
13+
14+const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId);
15+
16+// ---------------------------------------------------------------------------
17+// Payloads
18+// ---------------------------------------------------------------------------
19+
20+const AddSourcePayload = Schema.Struct({
21+ endpoint: Schema.String,
22+ introspectionJson: Schema.optional(Schema.String),
23+ namespace: Schema.optional(Schema.String),
24+ headers: Schema.optional(
25+ Schema.Record({ key: Schema.String, value: Schema.Unknown }),
26+ ),
27+});
28+
29+// ---------------------------------------------------------------------------
30+// Responses
31+// ---------------------------------------------------------------------------
32+
33+const AddSourceResponse = Schema.Struct({
34+ toolCount: Schema.Number,
35+ namespace: Schema.String,
36+});
37+
38+// ---------------------------------------------------------------------------
39+// Errors with HTTP status
40+// ---------------------------------------------------------------------------
41+
42+const IntrospectionError = GraphqlIntrospectionError.annotations(
43+ HttpApiSchema.annotations({ status: 400 }),
44+);
45+const ExtractionError = GraphqlExtractionError.annotations(
46+ HttpApiSchema.annotations({ status: 400 }),
47+);
48+
49+// ---------------------------------------------------------------------------
50+// Group
51+// ---------------------------------------------------------------------------
52+
53+export class GraphqlGroup extends HttpApiGroup.make("graphql")
54+ .add(
55+ HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/graphql/sources`
56+ .setPayload(AddSourcePayload)
57+ .addSuccess(AddSourceResponse)
58+ .addError(IntrospectionError)
59+ .addError(ExtractionError),
60+ )
61+ .prefix("/v1") {}
packages/plugins/graphql/src/api/handlers.ts+45−0
@@ -0,0 +1,45 @@
1+import { HttpApiBuilder } from "@effect/platform";
2+import { Context, Effect } from "effect";
3+
4+import { addGroup } from "@executor/api";
5+import type { GraphqlPluginExtension, HeaderValue } from "../sdk/plugin";
6+import { GraphqlGroup } from "./group";
7+
8+// ---------------------------------------------------------------------------
9+// Service tag — the server provides the GraphQL extension
10+// ---------------------------------------------------------------------------
11+
12+export class GraphqlExtensionService extends Context.Tag(
13+ "GraphqlExtensionService",
14+)<GraphqlExtensionService, GraphqlPluginExtension>() {}
15+
16+// ---------------------------------------------------------------------------
17+// Composed API — core + graphql group
18+// ---------------------------------------------------------------------------
19+
20+const ExecutorApiWithGraphql = addGroup(GraphqlGroup);
21+
22+// ---------------------------------------------------------------------------
23+// Handlers
24+// ---------------------------------------------------------------------------
25+
26+export const GraphqlHandlers = HttpApiBuilder.group(
27+ ExecutorApiWithGraphql,
28+ "graphql",
29+ (handlers) =>
30+ handlers.handle("addSource", ({ payload }) =>
31+ Effect.gen(function* () {
32+ const ext = yield* GraphqlExtensionService;
33+ const result = yield* ext.addSource({
34+ endpoint: payload.endpoint,
35+ introspectionJson: payload.introspectionJson,
36+ namespace: payload.namespace,
37+ headers: payload.headers as Record<string, HeaderValue> | undefined,
38+ });
39+ return {
40+ toolCount: result.toolCount,
41+ namespace: payload.namespace ?? "graphql",
42+ };
43+ }).pipe(Effect.orDie),
44+ ),
45+);
packages/plugins/graphql/src/api/index.ts+2−0
@@ -0,0 +1,2 @@
1+export { GraphqlGroup } from "./group";
2+export { GraphqlHandlers, GraphqlExtensionService } from "./handlers";
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+275−0
@@ -0,0 +1,275 @@
1+import { useState } from "react";
2+import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react";
3+
4+import {
5+ secretsAtom,
6+ setSecret,
7+ ScopeId,
8+ SecretPicker,
9+ type SecretPickerSecret,
10+} from "@executor/react";
11+import { SecretId } from "@executor/sdk";
12+import { Button } from "@executor/ui/components/button";
13+import { Input } from "@executor/ui/components/input";
14+import { Label } from "@executor/ui/components/label";
15+import { Spinner } from "@executor/ui/components/spinner";
16+import { addGraphqlSource } from "./atoms";
17+import type { HeaderValue } from "../sdk/types";
18+
19+// ---------------------------------------------------------------------------
20+// Inline secret creation
21+// ---------------------------------------------------------------------------
22+
23+function InlineCreateSecret(props: {
24+ headerName: string;
25+ suggestedId: string;
26+ onCreated: (secretId: string) => void;
27+ onCancel: () => void;
28+}) {
29+ const [secretId, setSecretId] = useState(props.suggestedId);
30+ const [secretName, setSecretName] = useState(props.headerName);
31+ const [secretValue, setSecretValue] = useState("");
32+ const [saving, setSaving] = useState(false);
33+ const [error, setError] = useState<string | null>(null);
34+ const doSet = useAtomSet(setSecret, { mode: "promise" });
35+ const refreshSecrets = useAtomRefresh(secretsAtom());
36+
37+ const handleSave = async () => {
38+ if (!secretId.trim() || !secretValue.trim()) return;
39+ setSaving(true);
40+ setError(null);
41+ try {
42+ await doSet({
43+ path: { scopeId: ScopeId.make("default") },
44+ payload: {
45+ id: SecretId.make(secretId.trim()),
46+ name: secretName.trim() || secretId.trim(),
47+ value: secretValue.trim(),
48+ purpose: `Auth header: ${props.headerName}`,
49+ },
50+ });
51+ refreshSecrets();
52+ props.onCreated(secretId.trim());
53+ } catch (e) {
54+ setError(e instanceof Error ? e.message : "Failed to save secret");
55+ setSaving(false);
56+ }
57+ };
58+
59+ return (
60+ <div className="rounded-lg border border-primary/20 bg-primary/[0.02] p-3 space-y-2.5">
61+ <p className="text-[11px] font-semibold text-primary tracking-wide uppercase">New secret</p>
62+ <div className="grid grid-cols-2 gap-2">
63+ <div className="space-y-1">
64+ <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">ID</Label>
65+ <Input
66+ value={secretId}
67+ onChange={(e) => setSecretId((e.target as HTMLInputElement).value)}
68+ placeholder="my-api-token"
69+ className="h-8 text-xs font-mono"
70+ />
71+ </div>
72+ <div className="space-y-1">
73+ <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Label</Label>
74+ <Input
75+ value={secretName}
76+ onChange={(e) => setSecretName((e.target as HTMLInputElement).value)}
77+ placeholder="API Token"
78+ className="h-8 text-xs"
79+ />
80+ </div>
81+ </div>
82+ <div className="space-y-1">
83+ <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Value</Label>
84+ <Input
85+ type="password"
86+ value={secretValue}
87+ onChange={(e) => setSecretValue((e.target as HTMLInputElement).value)}
88+ placeholder="paste your token or key..."
89+ className="h-8 text-xs font-mono"
90+ />
91+ </div>
92+ {error && <p className="text-[11px] text-destructive">{error}</p>}
93+ <div className="flex gap-1.5 pt-0.5">
94+ <Button variant="outline" size="xs" onClick={props.onCancel}>
95+ Cancel
96+ </Button>
97+ <Button
98+ size="xs"
99+ onClick={handleSave}
100+ disabled={!secretId.trim() || !secretValue.trim() || saving}
101+ >
102+ {saving ? "Saving..." : "Create & use"}
103+ </Button>
104+ </div>
105+ </div>
106+ );
107+}
108+
109+// ---------------------------------------------------------------------------
110+// Auth header row
111+// ---------------------------------------------------------------------------
112+
113+function AuthHeaderRow(props: {
114+ selectedSecretId: string | null;
115+ onSelect: (secretId: string) => void;
116+ existingSecrets: readonly SecretPickerSecret[];
117+}) {
118+ const [creating, setCreating] = useState(false);
119+ const { selectedSecretId, onSelect, existingSecrets } = props;
120+
121+ if (creating) {
122+ return (
123+ <InlineCreateSecret
124+ headerName="Authorization"
125+ suggestedId="graphql-auth-token"
126+ onCreated={(id) => {
127+ onSelect(id);
128+ setCreating(false);
129+ }}
130+ onCancel={() => setCreating(false)}
131+ />
132+ );
133+ }
134+
135+ return (
136+ <div className="space-y-1.5">
137+ <div className="flex items-center gap-1.5">
138+ <div className="flex-1 min-w-0">
139+ <SecretPicker
140+ value={selectedSecretId}
141+ onSelect={onSelect}
142+ secrets={existingSecrets}
143+ />
144+ </div>
145+ <Button variant="outline" size="sm" className="shrink-0" onClick={() => setCreating(true)}>
146+ + New
147+ </Button>
148+ </div>
149+ </div>
150+ );
151+}
152+
153+// ---------------------------------------------------------------------------
154+// Main component
155+// ---------------------------------------------------------------------------
156+
157+export default function AddGraphqlSource(props: {
158+ onComplete: () => void;
159+ onCancel: () => void;
160+}) {
161+ const [endpoint, setEndpoint] = useState("");
162+ const [namespace, setNamespace] = useState("");
163+ const [authSecretId, setAuthSecretId] = useState<string | null>(null);
164+ const [adding, setAdding] = useState(false);
165+ const [addError, setAddError] = useState<string | null>(null);
166+
167+ const doAdd = useAtomSet(addGraphqlSource, { mode: "promise" });
168+ const secrets = useAtomValue(secretsAtom());
169+
170+ const secretList: readonly SecretPickerSecret[] = Result.match(secrets, {
171+ onInitial: () => [] as SecretPickerSecret[],
172+ onFailure: () => [] as SecretPickerSecret[],
173+ onSuccess: ({ value }) =>
174+ value.map((s) => ({
175+ id: s.id,
176+ name: s.name,
177+ provider: s.provider ? String(s.provider) : undefined,
178+ })),
179+ });
180+
181+ const canAdd = endpoint.trim().length > 0;
182+
183+ const handleAdd = async () => {
184+ setAdding(true);
185+ setAddError(null);
186+ try {
187+ const headers: Record<string, HeaderValue> = {};
188+ if (authSecretId) {
189+ headers["Authorization"] = { secretId: authSecretId, prefix: "Bearer " };
190+ }
191+
192+ await doAdd({
193+ path: { scopeId: "default" as never },
194+ payload: {
195+ endpoint: endpoint.trim(),
196+ namespace: namespace.trim() || undefined,
197+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
198+ },
199+ });
200+ props.onComplete();
201+ } catch (e) {
202+ setAddError(e instanceof Error ? e.message : "Failed to add source");
203+ setAdding(false);
204+ }
205+ };
206+
207+ return (
208+ <div className="space-y-6">
209+ <h1 className="text-xl font-semibold text-foreground">Add GraphQL Source</h1>
210+
211+ {/* Endpoint */}
212+ <section className="space-y-2">
213+ <Label>GraphQL Endpoint</Label>
214+ <Input
215+ value={endpoint}
216+ onChange={(e) => setEndpoint((e.target as HTMLInputElement).value)}
217+ placeholder="https://api.example.com/graphql"
218+ className="font-mono text-sm"
219+ />
220+ <p className="text-[12px] text-muted-foreground">
221+ The endpoint will be introspected to discover available queries and mutations.
222+ </p>
223+ </section>
224+
225+ {/* Namespace */}
226+ <section className="space-y-2">
227+ <Label>
228+ Namespace <span className="text-muted-foreground font-normal">(optional)</span>
229+ </Label>
230+ <Input
231+ value={namespace}
232+ onChange={(e) => setNamespace((e.target as HTMLInputElement).value)}
233+ placeholder="my_api"
234+ className="font-mono text-sm"
235+ />
236+ <p className="text-[12px] text-muted-foreground">
237+ A prefix for the tool names. Derived from the endpoint hostname if not provided.
238+ </p>
239+ </section>
240+
241+ {/* Authentication */}
242+ <section className="space-y-2.5">
243+ <Label>
244+ Authentication <span className="text-muted-foreground font-normal">(optional)</span>
245+ </Label>
246+ <p className="text-[12px] text-muted-foreground">
247+ Select a secret for the Bearer token sent with every request, including introspection.
248+ </p>
249+ <AuthHeaderRow
250+ selectedSecretId={authSecretId}
251+ onSelect={setAuthSecretId}
252+ existingSecrets={secretList}
253+ />
254+ </section>
255+
256+ {/* Error */}
257+ {addError && (
258+ <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
259+ <p className="text-[12px] text-destructive">{addError}</p>
260+ </div>
261+ )}
262+
263+ {/* Actions */}
264+ <div className="flex items-center justify-between border-t border-border pt-4">
265+ <Button variant="ghost" onClick={props.onCancel} disabled={adding}>
266+ Cancel
267+ </Button>
268+ <Button onClick={handleAdd} disabled={!canAdd || adding}>
269+ {adding && <Spinner className="size-3.5" />}
270+ {adding ? "Adding..." : "Add source"}
271+ </Button>
272+ </div>
273+ </div>
274+ );
275+}
packages/plugins/graphql/src/react/EditGraphqlSource.tsx+12−0
@@ -0,0 +1,12 @@
1+export default function EditGraphqlSource(props: {
2+ sourceId: string;
3+ onSave: () => void;
4+}) {
5+ return (
6+ <div>
7+ <h3>Edit GraphQL Source</h3>
8+ <p>Source: {props.sourceId}</p>
9+ <button onClick={props.onSave}>Save</button>
10+ </div>
11+ );
12+}
packages/plugins/graphql/src/react/GraphqlSourceSummary.tsx+9−0
@@ -0,0 +1,9 @@
1+export default function GraphqlSourceSummary(props: {
2+ sourceId: string;
3+}) {
4+ return (
5+ <span>
6+ GraphQL · {props.sourceId}
7+ </span>
8+ );
9+}
packages/plugins/graphql/src/react/atoms.ts+7−0
@@ -0,0 +1,7 @@
1+import { GraphqlClient } from "./client";
2+
3+// ---------------------------------------------------------------------------
4+// Mutation atoms
5+// ---------------------------------------------------------------------------
6+
7+export const addGraphqlSource = GraphqlClient.mutation("graphql", "addSource");
packages/plugins/graphql/src/react/client.ts+20−0
@@ -0,0 +1,20 @@
1+import { AtomHttpApi } from "@effect-atom/atom-react";
2+import { FetchHttpClient } from "@effect/platform";
3+import { addGroup } from "@executor/api";
4+import { getBaseUrl } from "@executor/react";
5+import { GraphqlGroup } from "../api/group";
6+
7+// ---------------------------------------------------------------------------
8+// GraphQL-aware client — core routes + graphql routes
9+// ---------------------------------------------------------------------------
10+
11+const GraphqlApi = addGroup(GraphqlGroup);
12+
13+export const GraphqlClient = AtomHttpApi.Tag<"GraphqlClient">()(
14+ "GraphqlClient",
15+ {
16+ api: GraphqlApi,
17+ httpClient: FetchHttpClient.layer,
18+ baseUrl: getBaseUrl(),
19+ },
20+);
packages/plugins/graphql/src/react/index.ts+3−0
@@ -0,0 +1,3 @@
1+export { graphqlSourcePlugin } from "./source-plugin";
2+export { GraphqlClient } from "./client";
3+export { addGraphqlSource } from "./atoms";
packages/plugins/graphql/src/react/source-plugin.ts+14−0
@@ -0,0 +1,14 @@
1+import { lazy } from "react";
2+import type { SourcePlugin } from "@executor/react";
3+
4+// ---------------------------------------------------------------------------
5+// GraphQL source plugin — lazy-loaded components
6+// ---------------------------------------------------------------------------
7+
8+export const graphqlSourcePlugin: SourcePlugin = {
9+ key: "graphql",
10+ label: "GraphQL",
11+ add: lazy(() => import("./AddGraphqlSource")),
12+ edit: lazy(() => import("./EditGraphqlSource")),
13+ summary: lazy(() => import("./GraphqlSourceSummary")),
14+};
packages/plugins/graphql/src/sdk/errors.ts+25−0
@@ -0,0 +1,25 @@
1+import { Schema } from "effect";
2+
3+export class GraphqlIntrospectionError extends Schema.TaggedError<GraphqlIntrospectionError>()(
4+ "GraphqlIntrospectionError",
5+ {
6+ message: Schema.String,
7+ error: Schema.Defect,
8+ },
9+) {}
10+
11+export class GraphqlExtractionError extends Schema.TaggedError<GraphqlExtractionError>()(
12+ "GraphqlExtractionError",
13+ {
14+ message: Schema.String,
15+ },
16+) {}
17+
18+export class GraphqlInvocationError extends Schema.TaggedError<GraphqlInvocationError>()(
19+ "GraphqlInvocationError",
20+ {
21+ message: Schema.String,
22+ statusCode: Schema.optionalWith(Schema.Number, { as: "Option" }),
23+ error: Schema.Defect,
24+ },
25+) {}
packages/plugins/graphql/src/sdk/extract.test.ts+260−0
@@ -0,0 +1,260 @@
1+import { describe, it, expect } from "@effect/vitest";
2+import { Effect } from "effect";
3+
4+import { extract } from "./extract";
5+import type { IntrospectionResult } from "./introspect";
6+
7+// ---------------------------------------------------------------------------
8+// Minimal introspection fixture
9+// ---------------------------------------------------------------------------
10+
11+const makeIntrospection = (overrides?: Partial<IntrospectionResult["__schema"]>): IntrospectionResult => ({
12+ __schema: {
13+ queryType: { name: "Query" },
14+ mutationType: { name: "Mutation" },
15+ types: [
16+ {
17+ kind: "OBJECT",
18+ name: "Query",
19+ description: null,
20+ fields: [
21+ {
22+ name: "user",
23+ description: "Fetch a user by ID",
24+ args: [
25+ {
26+ name: "id",
27+ description: "User ID",
28+ type: { kind: "NON_NULL", name: null, ofType: { kind: "SCALAR", name: "ID", ofType: null } },
29+ defaultValue: null,
30+ },
31+ ],
32+ type: { kind: "OBJECT", name: "User", ofType: null },
33+ },
34+ {
35+ name: "users",
36+ description: "List all users",
37+ args: [],
38+ type: {
39+ kind: "NON_NULL",
40+ name: null,
41+ ofType: {
42+ kind: "LIST",
43+ name: null,
44+ ofType: { kind: "OBJECT", name: "User", ofType: null },
45+ },
46+ },
47+ },
48+ ],
49+ inputFields: null,
50+ enumValues: null,
51+ },
52+ {
53+ kind: "OBJECT",
54+ name: "Mutation",
55+ description: null,
56+ fields: [
57+ {
58+ name: "createUser",
59+ description: "Create a new user",
60+ args: [
61+ {
62+ name: "name",
63+ description: null,
64+ type: { kind: "NON_NULL", name: null, ofType: { kind: "SCALAR", name: "String", ofType: null } },
65+ defaultValue: null,
66+ },
67+ {
68+ name: "email",
69+ description: null,
70+ type: { kind: "SCALAR", name: "String", ofType: null },
71+ defaultValue: null,
72+ },
73+ ],
74+ type: { kind: "OBJECT", name: "User", ofType: null },
75+ },
76+ ],
77+ inputFields: null,
78+ enumValues: null,
79+ },
80+ {
81+ kind: "OBJECT",
82+ name: "User",
83+ description: null,
84+ fields: [
85+ { name: "id", description: null, args: [], type: { kind: "NON_NULL", name: null, ofType: { kind: "SCALAR", name: "ID", ofType: null } } },
86+ { name: "name", description: null, args: [], type: { kind: "SCALAR", name: "String", ofType: null } },
87+ { name: "email", description: null, args: [], type: { kind: "SCALAR", name: "String", ofType: null } },
88+ ],
89+ inputFields: null,
90+ enumValues: null,
91+ },
92+ { kind: "SCALAR", name: "ID", description: null, fields: null, inputFields: null, enumValues: null },
93+ { kind: "SCALAR", name: "String", description: null, fields: null, inputFields: null, enumValues: null },
94+ { kind: "SCALAR", name: "Boolean", description: null, fields: null, inputFields: null, enumValues: null },
95+ ],
96+ ...overrides,
97+ },
98+});
99+
100+// ---------------------------------------------------------------------------
101+// Tests
102+// ---------------------------------------------------------------------------
103+
104+describe("extract", () => {
105+ it("extracts queries and mutations from introspection result", async () => {
106+ const { result } = await Effect.runPromise(extract(makeIntrospection()));
107+
108+ const queries = result.fields.filter((f) => f.kind === "query");
109+ const mutations = result.fields.filter((f) => f.kind === "mutation");
110+
111+ expect(queries).toHaveLength(2);
112+ expect(mutations).toHaveLength(1);
113+
114+ const userQuery = queries.find((f) => f.fieldName === "user");
115+ expect(userQuery).toBeDefined();
116+ expect(userQuery!.arguments).toHaveLength(1);
117+ expect(userQuery!.arguments[0].name).toBe("id");
118+ expect(userQuery!.arguments[0].required).toBe(true);
119+ expect(userQuery!.returnTypeName).toBe("User");
120+
121+ const createMutation = mutations.find((f) => f.fieldName === "createUser");
122+ expect(createMutation).toBeDefined();
123+ expect(createMutation!.arguments).toHaveLength(2);
124+ expect(createMutation!.arguments[0].name).toBe("name");
125+ expect(createMutation!.arguments[0].required).toBe(true);
126+ expect(createMutation!.arguments[1].name).toBe("email");
127+ expect(createMutation!.arguments[1].required).toBe(false);
128+ });
129+
130+ it("builds input schema with required fields", async () => {
131+ const { result } = await Effect.runPromise(extract(makeIntrospection()));
132+ const createUser = result.fields.find((f) => f.fieldName === "createUser");
133+ expect(createUser).toBeDefined();
134+
135+ const inputSchema = createUser!.inputSchema as any;
136+ expect(inputSchema.value).toBeDefined();
137+ const schema = inputSchema.value;
138+ expect(schema.type).toBe("object");
139+ expect(schema.properties.name.type).toBe("string");
140+ expect(schema.required).toContain("name");
141+ expect(schema.required).not.toContain("email");
142+ });
143+
144+ it("handles schema with no mutations", async () => {
145+ const { result } = await Effect.runPromise(
146+ extract(makeIntrospection({ mutationType: null })),
147+ );
148+
149+ const mutations = result.fields.filter((f) => f.kind === "mutation");
150+ expect(mutations).toHaveLength(0);
151+ });
152+
153+ it("handles empty query type", async () => {
154+ const { result } = await Effect.runPromise(
155+ extract(makeIntrospection({ queryType: null })),
156+ );
157+
158+ const queries = result.fields.filter((f) => f.kind === "query");
159+ expect(queries).toHaveLength(0);
160+ });
161+
162+ it("handles circular input type references without stack overflow", async () => {
163+ const circular: IntrospectionResult = {
164+ __schema: {
165+ queryType: { name: "Query" },
166+ mutationType: null,
167+ types: [
168+ {
169+ kind: "OBJECT",
170+ name: "Query",
171+ description: null,
172+ fields: [
173+ {
174+ name: "issues",
175+ description: "List issues",
176+ args: [
177+ {
178+ name: "filter",
179+ description: null,
180+ type: { kind: "INPUT_OBJECT", name: "IssueFilter", ofType: null },
181+ defaultValue: null,
182+ },
183+ ],
184+ type: { kind: "OBJECT", name: "Issue", ofType: null },
185+ },
186+ ],
187+ inputFields: null,
188+ enumValues: null,
189+ },
190+ {
191+ kind: "INPUT_OBJECT",
192+ name: "IssueFilter",
193+ description: null,
194+ fields: null,
195+ inputFields: [
196+ {
197+ name: "title",
198+ description: null,
199+ type: { kind: "SCALAR", name: "String", ofType: null },
200+ defaultValue: null,
201+ },
202+ {
203+ name: "and",
204+ description: "Compound filter",
205+ type: {
206+ kind: "LIST",
207+ name: null,
208+ ofType: { kind: "INPUT_OBJECT", name: "IssueFilter", ofType: null },
209+ },
210+ defaultValue: null,
211+ },
212+ {
213+ name: "or",
214+ description: null,
215+ type: {
216+ kind: "LIST",
217+ name: null,
218+ ofType: { kind: "INPUT_OBJECT", name: "IssueFilter", ofType: null },
219+ },
220+ defaultValue: null,
221+ },
222+ ],
223+ enumValues: null,
224+ },
225+ {
226+ kind: "OBJECT",
227+ name: "Issue",
228+ description: null,
229+ fields: [
230+ { name: "id", description: null, args: [], type: { kind: "SCALAR", name: "ID", ofType: null } },
231+ ],
232+ inputFields: null,
233+ enumValues: null,
234+ },
235+ { kind: "SCALAR", name: "String", description: null, fields: null, inputFields: null, enumValues: null },
236+ { kind: "SCALAR", name: "ID", description: null, fields: null, inputFields: null, enumValues: null },
237+ ],
238+ },
239+ };
240+
241+ // Should not throw "Maximum call stack size exceeded"
242+ const { result, definitions } = await Effect.runPromise(extract(circular));
243+ expect(result.fields).toHaveLength(1);
244+
245+ const issuesField = result.fields[0];
246+ expect(issuesField.fieldName).toBe("issues");
247+
248+ // The filter arg should use a $ref, not inline the full type
249+ const schema = (issuesField.inputSchema as any).value;
250+ expect(schema.properties.filter.$ref).toBe("#/$defs/IssueFilter");
251+
252+ // The definition should exist with proper fields
253+ const filterDef = definitions["IssueFilter"] as any;
254+ expect(filterDef.type).toBe("object");
255+ expect(filterDef.properties.title.type).toBe("string");
256+ // Self-referential "and" field uses $ref back to itself
257+ expect(filterDef.properties.and.type).toBe("array");
258+ expect(filterDef.properties.and.items.$ref).toBe("#/$defs/IssueFilter");
259+ });
260+});
packages/plugins/graphql/src/sdk/extract.ts+270−0
@@ -0,0 +1,270 @@
1+import { Effect, Option } from "effect";
2+
3+import { GraphqlExtractionError } from "./errors";
4+import type {
5+ IntrospectionResult,
6+ IntrospectionSchema,
7+ IntrospectionType,
8+ IntrospectionTypeRef,
9+ IntrospectionInputValue,
10+} from "./introspect";
11+import {
12+ ExtractedField,
13+ ExtractionResult,
14+ GraphqlArgument,
15+ type GraphqlOperationKind,
16+} from "./types";
17+
18+// ---------------------------------------------------------------------------
19+// Type ref helpers
20+// ---------------------------------------------------------------------------
21+
22+/** Unwrap NON_NULL / LIST wrappers to get the leaf type name */
23+const unwrapTypeName = (ref: IntrospectionTypeRef): string => {
24+ if (ref.name) return ref.name;
25+ if (ref.ofType) return unwrapTypeName(ref.ofType);
26+ return "Unknown";
27+};
28+
29+/** Check if a type ref is non-null (required) */
30+const isNonNull = (ref: IntrospectionTypeRef): boolean =>
31+ ref.kind === "NON_NULL";
32+
33+// ---------------------------------------------------------------------------
34+// Build shared definitions from all INPUT_OBJECT and ENUM types
35+// ---------------------------------------------------------------------------
36+
37+const buildDefinitions = (
38+ types: ReadonlyMap<string, IntrospectionType>,
39+): Record<string, unknown> => {
40+ const defs: Record<string, unknown> = {};
41+
42+ for (const [name, type] of types) {
43+ // Skip internal types
44+ if (name.startsWith("__")) continue;
45+
46+ if (type.kind === "INPUT_OBJECT" && type.inputFields) {
47+ const properties: Record<string, unknown> = {};
48+ const required: string[] = [];
49+
50+ for (const field of type.inputFields) {
51+ const schema = typeRefToJsonSchema(field.type, types);
52+ if (field.description) {
53+ (schema as Record<string, unknown>).description = field.description;
54+ }
55+ properties[field.name] = schema;
56+ if (isNonNull(field.type)) {
57+ required.push(field.name);
58+ }
59+ }
60+
61+ const def: Record<string, unknown> = { type: "object", properties };
62+ if (required.length > 0) def.required = required;
63+ if (type.description) def.description = type.description;
64+ defs[name] = def;
65+ }
66+
67+ if (type.kind === "ENUM" && type.enumValues) {
68+ defs[name] = {
69+ type: "string",
70+ enum: type.enumValues.map((v) => v.name),
71+ ...(type.description ? { description: type.description } : {}),
72+ };
73+ }
74+ }
75+
76+ return defs;
77+};
78+
79+// ---------------------------------------------------------------------------
80+// Convert a type ref to JSON Schema using $ref for complex types
81+// ---------------------------------------------------------------------------
82+
83+const typeRefToJsonSchema = (
84+ ref: IntrospectionTypeRef,
85+ types: ReadonlyMap<string, IntrospectionType>,
86+): Record<string, unknown> => {
87+ switch (ref.kind) {
88+ case "NON_NULL":
89+ return ref.ofType ? typeRefToJsonSchema(ref.ofType, types) : {};
90+
91+ case "LIST":
92+ return {
93+ type: "array",
94+ items: ref.ofType ? typeRefToJsonSchema(ref.ofType, types) : {},
95+ };
96+
97+ case "SCALAR":
98+ return scalarToJsonSchema(ref.name ?? "String");
99+
100+ case "ENUM":
101+ // Reference the shared definition
102+ return ref.name ? { $ref: `#/$defs/${ref.name}` } : { type: "string" };
103+
104+ case "INPUT_OBJECT":
105+ // Reference the shared definition — no recursive expansion needed
106+ return ref.name ? { $ref: `#/$defs/${ref.name}` } : { type: "object" };
107+
108+ case "OBJECT":
109+ case "INTERFACE":
110+ case "UNION":
111+ return { type: "object" };
112+
113+ default:
114+ return {};
115+ }
116+};
117+
118+const scalarToJsonSchema = (name: string): Record<string, unknown> => {
119+ switch (name) {
120+ case "String":
121+ case "ID":
122+ return { type: "string" };
123+ case "Int":
124+ return { type: "integer" };
125+ case "Float":
126+ return { type: "number" };
127+ case "Boolean":
128+ return { type: "boolean" };
129+ default:
130+ return { type: "string", description: `Custom scalar: ${name}` };
131+ }
132+};
133+
134+// ---------------------------------------------------------------------------
135+// Build input JSON Schema from field arguments
136+// ---------------------------------------------------------------------------
137+
138+const buildInputSchema = (
139+ args: readonly IntrospectionInputValue[],
140+ types: ReadonlyMap<string, IntrospectionType>,
141+): Record<string, unknown> | undefined => {
142+ if (args.length === 0) return undefined;
143+
144+ const properties: Record<string, unknown> = {};
145+ const required: string[] = [];
146+
147+ for (const arg of args) {
148+ const schema = typeRefToJsonSchema(arg.type, types);
149+ if (arg.description) {
150+ (schema as Record<string, unknown>).description = arg.description;
151+ }
152+ properties[arg.name] = schema;
153+ if (isNonNull(arg.type)) {
154+ required.push(arg.name);
155+ }
156+ }
157+
158+ const inputSchema: Record<string, unknown> = {
159+ type: "object",
160+ properties,
161+ };
162+ if (required.length > 0) inputSchema.required = required;
163+ return inputSchema;
164+};
165+
166+/** Format a type ref back to GraphQL type notation (e.g. "[String!]!") */
167+const formatTypeRef = (ref: IntrospectionTypeRef): string => {
168+ switch (ref.kind) {
169+ case "NON_NULL":
170+ return ref.ofType ? `${formatTypeRef(ref.ofType)}!` : "Unknown!";
171+ case "LIST":
172+ return ref.ofType ? `[${formatTypeRef(ref.ofType)}]` : "[Unknown]";
173+ default:
174+ return ref.name ?? "Unknown";
175+ }
176+};
177+
178+// ---------------------------------------------------------------------------
179+// Extract fields from schema
180+// ---------------------------------------------------------------------------
181+
182+const extractFields = (
183+ _schema: IntrospectionSchema,
184+ kind: GraphqlOperationKind,
185+ typeName: string | null | undefined,
186+ types: ReadonlyMap<string, IntrospectionType>,
187+): ExtractedField[] => {
188+ if (!typeName) return [];
189+
190+ const type = types.get(typeName);
191+ if (!type?.fields) return [];
192+
193+ return type.fields
194+ .filter((f) => !f.name.startsWith("__"))
195+ .map((field) => {
196+ const args = field.args.map(
197+ (arg) =>
198+ new GraphqlArgument({
199+ name: arg.name,
200+ typeName: formatTypeRef(arg.type),
201+ required: isNonNull(arg.type),
202+ description: arg.description ? Option.some(arg.description) : Option.none(),
203+ }),
204+ );
205+
206+ const inputSchema = buildInputSchema(field.args, types);
207+
208+ return new ExtractedField({
209+ fieldName: field.name,
210+ kind,
211+ description: field.description
212+ ? Option.some(field.description)
213+ : Option.none(),
214+ arguments: args,
215+ inputSchema: inputSchema ? Option.some(inputSchema) : Option.none(),
216+ returnTypeName: unwrapTypeName(field.type),
217+ });
218+ });
219+};
220+
221+// ---------------------------------------------------------------------------
222+// Public API
223+// ---------------------------------------------------------------------------
224+
225+export interface ExtractionOutput {
226+ readonly result: ExtractionResult;
227+ /** Shared JSON Schema definitions for INPUT_OBJECT and ENUM types.
228+ * Tool input schemas use `$ref` pointers into these. */
229+ readonly definitions: Record<string, unknown>;
230+}
231+
232+export const extract = (
233+ introspection: IntrospectionResult,
234+): Effect.Effect<ExtractionOutput, GraphqlExtractionError> =>
235+ Effect.try({
236+ try: () => {
237+ const schema = introspection.__schema;
238+ const typeMap = new Map<string, IntrospectionType>();
239+ for (const t of schema.types) {
240+ typeMap.set(t.name, t);
241+ }
242+
243+ const definitions = buildDefinitions(typeMap);
244+
245+ const queryFields = extractFields(
246+ schema,
247+ "query",
248+ schema.queryType?.name,
249+ typeMap,
250+ );
251+ const mutationFields = extractFields(
252+ schema,
253+ "mutation",
254+ schema.mutationType?.name,
255+ typeMap,
256+ );
257+
258+ return {
259+ result: new ExtractionResult({
260+ schemaName: Option.none(),
261+ fields: [...queryFields, ...mutationFields],
262+ }),
263+ definitions,
264+ };
265+ },
266+ catch: (err) =>
267+ new GraphqlExtractionError({
268+ message: `Failed to extract GraphQL schema: ${err instanceof Error ? err.message : String(err)}`,
269+ }),
270+ });
packages/plugins/graphql/src/sdk/index.ts+33−0
@@ -0,0 +1,33 @@
1+export { introspect, parseIntrospectionJson } from "./introspect";
2+export { extract, type ExtractionOutput } from "./extract";
3+export { invoke, makeGraphqlInvoker } from "./invoke";
4+export {
5+ graphqlPlugin,
6+ type GraphqlSourceConfig,
7+ type GraphqlPluginExtension,
8+} from "./plugin";
9+export {
10+ type GraphqlOperationStore,
11+ type SourceMeta,
12+} from "./operation-store";
13+export {
14+ makeKvOperationStore,
15+ makeInMemoryOperationStore,
16+} from "./kv-operation-store";
17+
18+export {
19+ GraphqlIntrospectionError,
20+ GraphqlExtractionError,
21+ GraphqlInvocationError,
22+} from "./errors";
23+
24+export {
25+ ExtractedField,
26+ ExtractionResult,
27+ GraphqlArgument,
28+ GraphqlOperationKind,
29+ InvocationConfig,
30+ InvocationResult,
31+ OperationBinding,
32+ HeaderValue,
33+} from "./types";
packages/plugins/graphql/src/sdk/introspect.ts+220−0
@@ -0,0 +1,220 @@
1+import { Effect } from "effect";
2+import { HttpClient, HttpClientRequest } from "@effect/platform";
3+
4+import { GraphqlIntrospectionError } from "./errors";
5+
6+// ---------------------------------------------------------------------------
7+// Introspection query — standard GraphQL introspection
8+// ---------------------------------------------------------------------------
9+
10+const INTROSPECTION_QUERY = `
11+ query IntrospectionQuery {
12+ __schema {
13+ queryType { name }
14+ mutationType { name }
15+ types {
16+ kind
17+ name
18+ description
19+ fields(includeDeprecated: false) {
20+ name
21+ description
22+ args {
23+ name
24+ description
25+ type {
26+ ...TypeRef
27+ }
28+ defaultValue
29+ }
30+ type {
31+ ...TypeRef
32+ }
33+ }
34+ inputFields {
35+ name
36+ description
37+ type {
38+ ...TypeRef
39+ }
40+ defaultValue
41+ }
42+ enumValues(includeDeprecated: false) {
43+ name
44+ description
45+ }
46+ }
47+ }
48+ }
49+
50+ fragment TypeRef on __Type {
51+ kind
52+ name
53+ ofType {
54+ kind
55+ name
56+ ofType {
57+ kind
58+ name
59+ ofType {
60+ kind
61+ name
62+ ofType {
63+ kind
64+ name
65+ ofType {
66+ kind
67+ name
68+ ofType {
69+ kind
70+ name
71+ }
72+ }
73+ }
74+ }
75+ }
76+ }
77+ }
78+`;
79+
80+// ---------------------------------------------------------------------------
81+// Introspection result types
82+// ---------------------------------------------------------------------------
83+
84+export interface IntrospectionTypeRef {
85+ readonly kind: string;
86+ readonly name: string | null;
87+ readonly ofType: IntrospectionTypeRef | null;
88+}
89+
90+export interface IntrospectionInputValue {
91+ readonly name: string;
92+ readonly description: string | null;
93+ readonly type: IntrospectionTypeRef;
94+ readonly defaultValue: string | null;
95+}
96+
97+export interface IntrospectionField {
98+ readonly name: string;
99+ readonly description: string | null;
100+ readonly args: readonly IntrospectionInputValue[];
101+ readonly type: IntrospectionTypeRef;
102+}
103+
104+export interface IntrospectionEnumValue {
105+ readonly name: string;
106+ readonly description: string | null;
107+}
108+
109+export interface IntrospectionType {
110+ readonly kind: string;
111+ readonly name: string;
112+ readonly description: string | null;
113+ readonly fields: readonly IntrospectionField[] | null;
114+ readonly inputFields: readonly IntrospectionInputValue[] | null;
115+ readonly enumValues: readonly IntrospectionEnumValue[] | null;
116+}
117+
118+export interface IntrospectionSchema {
119+ readonly queryType: { readonly name: string } | null;
120+ readonly mutationType: { readonly name: string } | null;
121+ readonly types: readonly IntrospectionType[];
122+}
123+
124+export interface IntrospectionResult {
125+ readonly __schema: IntrospectionSchema;
126+}
127+
128+// ---------------------------------------------------------------------------
129+// Introspect a GraphQL endpoint
130+// ---------------------------------------------------------------------------
131+
132+export const introspect = Effect.fn("GraphQL.introspect")(function* (
133+ endpoint: string,
134+ headers?: Record<string, string>,
135+) {
136+ const client = yield* HttpClient.HttpClient;
137+
138+ let request = HttpClientRequest.post(endpoint).pipe(
139+ HttpClientRequest.setHeader("Content-Type", "application/json"),
140+ HttpClientRequest.bodyUnsafeJson({
141+ query: INTROSPECTION_QUERY,
142+ }),
143+ );
144+
145+ if (headers) {
146+ for (const [k, v] of Object.entries(headers)) {
147+ request = HttpClientRequest.setHeader(request, k, v);
148+ }
149+ }
150+
151+ const response = yield* client.execute(request).pipe(
152+ Effect.mapError(
153+ (err) =>
154+ new GraphqlIntrospectionError({
155+ message: `Failed to reach GraphQL endpoint: ${err.message}`,
156+ error: err,
157+ }),
158+ ),
159+ );
160+
161+ if (response.status !== 200) {
162+ const body = yield* response.text.pipe(Effect.catchAll(() => Effect.succeed("")));
163+ return yield* new GraphqlIntrospectionError({
164+ message: `Introspection failed with status ${response.status}: ${body}`,
165+ error: undefined,
166+ });
167+ }
168+
169+ const raw = yield* response.json.pipe(
170+ Effect.mapError(
171+ (err) =>
172+ new GraphqlIntrospectionError({
173+ message: `Failed to parse introspection response as JSON`,
174+ error: err,
175+ }),
176+ ),
177+ );
178+
179+ const json = raw as { data?: IntrospectionResult; errors?: unknown[] };
180+
181+ if (json.errors && Array.isArray(json.errors) && json.errors.length > 0) {
182+ return yield* new GraphqlIntrospectionError({
183+ message: `Introspection returned errors: ${JSON.stringify(json.errors)}`,
184+ error: undefined,
185+ });
186+ }
187+
188+ if (!json.data?.__schema) {
189+ return yield* new GraphqlIntrospectionError({
190+ message: "Introspection response missing __schema",
191+ error: undefined,
192+ });
193+ }
194+
195+ return json.data;
196+});
197+
198+// ---------------------------------------------------------------------------
199+// Parse an introspection result from a JSON string (for offline/text input)
200+// ---------------------------------------------------------------------------
201+
202+export const parseIntrospectionJson = (
203+ text: string,
204+): Effect.Effect<IntrospectionResult, GraphqlIntrospectionError> =>
205+ Effect.try({
206+ try: () => {
207+ const parsed = JSON.parse(text);
208+ // Accept both { data: { __schema } } and { __schema } formats
209+ const result = parsed.data ?? parsed;
210+ if (!result.__schema) {
211+ throw new Error("Missing __schema in introspection JSON");
212+ }
213+ return result as IntrospectionResult;
214+ },
215+ catch: (err) =>
216+ new GraphqlIntrospectionError({
217+ message: `Failed to parse introspection JSON: ${err instanceof Error ? err.message : String(err)}`,
218+ error: err,
219+ }),
220+ });
packages/plugins/graphql/src/sdk/invoke.ts+210−0
@@ -0,0 +1,210 @@
1+import { Effect, Layer, Option } from "effect";
2+import { HttpClient, HttpClientRequest } from "@effect/platform";
3+
4+import {
5+ type ToolId,
6+ type ToolInvoker,
7+ ToolInvocationResult,
8+ ToolInvocationError,
9+ type ScopeId,
10+ type SecretId,
11+} from "@executor/sdk";
12+
13+import { GraphqlInvocationError } from "./errors";
14+import type { GraphqlOperationStore } from "./operation-store";
15+import {
16+ type HeaderValue,
17+ type OperationBinding,
18+ InvocationConfig,
19+ InvocationResult,
20+} from "./types";
21+
22+// ---------------------------------------------------------------------------
23+// Header resolution — resolves secret refs at invocation time
24+// ---------------------------------------------------------------------------
25+
26+const resolveHeaders = (
27+ headers: Record<string, HeaderValue>,
28+ secrets: { readonly resolve: (secretId: SecretId, scopeId: ScopeId) => Effect.Effect<string, unknown> },
29+ scopeId: ScopeId,
30+): Effect.Effect<Record<string, string>, ToolInvocationError> =>
31+ Effect.gen(function* () {
32+ const resolved: Record<string, string> = {};
33+ for (const [name, value] of Object.entries(headers)) {
34+ if (typeof value === "string") {
35+ resolved[name] = value;
36+ } else {
37+ const secret = yield* secrets.resolve(value.secretId as SecretId, scopeId).pipe(
38+ Effect.mapError(() =>
39+ new ToolInvocationError({
40+ toolId: "" as ToolId,
41+ message: `Failed to resolve secret "${value.secretId}" for header "${name}"`,
42+ cause: undefined,
43+ }),
44+ ),
45+ );
46+ resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret;
47+ }
48+ }
49+ return resolved;
50+ });
51+
52+// ---------------------------------------------------------------------------
53+// Response helpers
54+// ---------------------------------------------------------------------------
55+
56+const isJsonContentType = (ct: string | null | undefined): boolean => {
57+ if (!ct) return false;
58+ const normalized = ct.split(";")[0]?.trim().toLowerCase() ?? "";
59+ return (
60+ normalized === "application/json" ||
61+ normalized.includes("+json") ||
62+ normalized.includes("json")
63+ );
64+};
65+
66+// ---------------------------------------------------------------------------
67+// Public API — execute a GraphQL operation
68+// ---------------------------------------------------------------------------
69+
70+export const invoke = Effect.fn("GraphQL.invoke")(function* (
71+ operation: OperationBinding,
72+ args: Record<string, unknown>,
73+ config: InvocationConfig,
74+ resolvedHeaders?: Record<string, string>,
75+) {
76+ const client = yield* HttpClient.HttpClient;
77+
78+ // Build the GraphQL request body
79+ const variables: Record<string, unknown> = {};
80+ for (const varName of operation.variableNames) {
81+ if (args[varName] !== undefined) {
82+ variables[varName] = args[varName];
83+ }
84+ }
85+
86+ // Also pick up any variables from a "variables" container
87+ if (typeof args.variables === "object" && args.variables !== null) {
88+ Object.assign(variables, args.variables);
89+ }
90+
91+ let request = HttpClientRequest.post(config.endpoint).pipe(
92+ HttpClientRequest.setHeader("Content-Type", "application/json"),
93+ HttpClientRequest.bodyUnsafeJson({
94+ query: operation.operationString,
95+ variables: Object.keys(variables).length > 0 ? variables : undefined,
96+ }),
97+ );
98+
99+ // Apply resolved headers
100+ if (resolvedHeaders) {
101+ for (const [name, value] of Object.entries(resolvedHeaders)) {
102+ request = HttpClientRequest.setHeader(request, name, value);
103+ }
104+ }
105+
106+ const response = yield* client.execute(request).pipe(
107+ Effect.mapError(
108+ (err) =>
109+ new GraphqlInvocationError({
110+ message: `GraphQL request failed: ${err.message}`,
111+ statusCode: Option.none(),
112+ error: err,
113+ }),
114+ ),
115+ );
116+
117+ const status = response.status;
118+ const contentType = response.headers["content-type"] ?? null;
119+
120+ const body: unknown = isJsonContentType(contentType)
121+ ? yield* response.json.pipe(Effect.catchAll(() => response.text))
122+ : yield* response.text;
123+
124+ // GraphQL responses are always 200 with { data, errors }
125+ const gqlBody = body as { data?: unknown; errors?: unknown[] } | null;
126+ const hasErrors = Array.isArray(gqlBody?.errors) && gqlBody.errors.length > 0;
127+
128+ return new InvocationResult({
129+ status,
130+ data: gqlBody?.data ?? null,
131+ errors: hasErrors ? gqlBody!.errors : null,
132+ });
133+});
134+
135+// ---------------------------------------------------------------------------
136+// ToolInvoker — bridges operation store + HTTP client into SDK invoker
137+// ---------------------------------------------------------------------------
138+
139+export const makeGraphqlInvoker = (opts: {
140+ readonly operationStore: GraphqlOperationStore;
141+ readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>;
142+ readonly secrets: { readonly resolve: (secretId: SecretId, scopeId: ScopeId) => Effect.Effect<string, unknown> };
143+ readonly scopeId: ScopeId;
144+}): ToolInvoker => ({
145+ resolveAnnotations: (toolId: ToolId) =>
146+ Effect.gen(function* () {
147+ const entry = yield* opts.operationStore.get(toolId);
148+ if (!entry) return undefined;
149+ // Mutations require approval, queries don't
150+ if (entry.binding.kind === "mutation") {
151+ return {
152+ requiresApproval: true,
153+ approvalDescription: `mutation ${entry.binding.fieldName}`,
154+ };
155+ }
156+ return {};
157+ }),
158+
159+ invoke: (toolId: ToolId, args: unknown) =>
160+ Effect.gen(function* () {
161+ const entry = yield* opts.operationStore.get(toolId);
162+ if (!entry) {
163+ return yield* new ToolInvocationError({
164+ toolId,
165+ message: `No GraphQL operation found for tool "${toolId}"`,
166+ cause: undefined,
167+ });
168+ }
169+
170+ const { binding, config } = entry;
171+
172+ // Resolve secret-backed headers
173+ const resolvedHeaders = yield* resolveHeaders(
174+ config.headers,
175+ opts.secrets,
176+ opts.scopeId,
177+ );
178+
179+ const result = yield* invoke(
180+ binding,
181+ (args ?? {}) as Record<string, unknown>,
182+ config,
183+ resolvedHeaders,
184+ ).pipe(Effect.provide(opts.httpClientLayer));
185+
186+ return new ToolInvocationResult({
187+ data: result.data,
188+ error: result.errors,
189+ status: result.status,
190+ });
191+ }).pipe(
192+ Effect.catchAll((err) => {
193+ if (
194+ typeof err === "object" &&
195+ err !== null &&
196+ "_tag" in err &&
197+ (err as { _tag: string })._tag === "ToolInvocationError"
198+ ) {
199+ return Effect.fail(err as ToolInvocationError);
200+ }
201+ return Effect.fail(
202+ new ToolInvocationError({
203+ toolId,
204+ message: `GraphQL invocation failed: ${err instanceof Error ? err.message : String(err)}`,
205+ cause: err,
206+ }),
207+ );
208+ }),
209+ ),
210+});
packages/plugins/graphql/src/sdk/kv-operation-store.ts+99−0
@@ -0,0 +1,99 @@
1+// ---------------------------------------------------------------------------
2+// KV-backed GraphqlOperationStore
3+// ---------------------------------------------------------------------------
4+
5+import { Effect, Schema } from "effect";
6+import { scopeKv, makeInMemoryScopedKv, type Kv, type ToolId, type ScopedKv } from "@executor/sdk";
7+
8+import type { GraphqlOperationStore, SourceMeta } from "./operation-store";
9+import { OperationBinding, InvocationConfig } from "./types";
10+
11+// ---------------------------------------------------------------------------
12+// Stored entry schema
13+// ---------------------------------------------------------------------------
14+
15+class StoredEntry extends Schema.Class<StoredEntry>("StoredEntry")({
16+ namespace: Schema.String,
17+ binding: OperationBinding,
18+ config: InvocationConfig,
19+}) {}
20+
21+const encodeEntry = Schema.encodeSync(Schema.parseJson(StoredEntry));
22+const decodeEntry = Schema.decodeUnknownSync(Schema.parseJson(StoredEntry));
23+
24+// ---------------------------------------------------------------------------
25+// Implementation
26+// ---------------------------------------------------------------------------
27+
28+const makeStore = (
29+ bindings: ScopedKv,
30+ meta: ScopedKv,
31+): GraphqlOperationStore => ({
32+ get: (toolId) =>
33+ Effect.gen(function* () {
34+ const raw = yield* bindings.get(toolId);
35+ if (!raw) return null;
36+ const entry = decodeEntry(raw);
37+ return { binding: entry.binding, config: entry.config };
38+ }),
39+
40+ put: (toolId, namespace, binding, config) =>
41+ bindings.set(
42+ toolId,
43+ encodeEntry(new StoredEntry({ namespace, binding, config })),
44+ ),
45+
46+ remove: (toolId) => bindings.delete(toolId).pipe(Effect.asVoid),
47+
48+ listByNamespace: (namespace) =>
49+ Effect.gen(function* () {
50+ const entries = yield* bindings.list();
51+ const ids: ToolId[] = [];
52+ for (const e of entries) {
53+ const entry = decodeEntry(e.value);
54+ if (entry.namespace === namespace) ids.push(e.key as ToolId);
55+ }
56+ return ids;
57+ }),
58+
59+ removeByNamespace: (namespace) =>
60+ Effect.gen(function* () {
61+ const entries = yield* bindings.list();
62+ const ids: ToolId[] = [];
63+ for (const e of entries) {
64+ const entry = decodeEntry(e.value);
65+ if (entry.namespace === namespace) {
66+ ids.push(e.key as ToolId);
67+ yield* bindings.delete(e.key);
68+ }
69+ }
70+ return ids;
71+ }),
72+
73+ putSourceMeta: (m) => meta.set(m.namespace, JSON.stringify(m)),
74+
75+ removeSourceMeta: (namespace) =>
76+ meta.delete(namespace).pipe(Effect.asVoid),
77+
78+ listSourceMeta: () =>
79+ Effect.gen(function* () {
80+ const entries = yield* meta.list();
81+ return entries.map((e) => JSON.parse(e.value) as SourceMeta);
82+ }),
83+});
84+
85+// ---------------------------------------------------------------------------
86+// Factory
87+// ---------------------------------------------------------------------------
88+
89+export const makeKvOperationStore = (
90+ kv: Kv,
91+ namespace: string,
92+): GraphqlOperationStore =>
93+ makeStore(
94+ scopeKv(kv, `${namespace}.bindings`),
95+ scopeKv(kv, `${namespace}.sources`),
96+ );
97+
98+export const makeInMemoryOperationStore = (): GraphqlOperationStore =>
99+ makeStore(makeInMemoryScopedKv(), makeInMemoryScopedKv());
packages/plugins/graphql/src/sdk/operation-store.ts+38−0
@@ -0,0 +1,38 @@
1+import type { Effect } from "effect";
2+import type { ToolId } from "@executor/sdk";
3+
4+import type { OperationBinding, InvocationConfig } from "./types";
5+
6+// ---------------------------------------------------------------------------
7+// Operation store — plugin's own storage for invocation data
8+// ---------------------------------------------------------------------------
9+
10+export interface SourceMeta {
11+ readonly namespace: string;
12+ readonly name: string;
13+}
14+
15+export interface GraphqlOperationStore {
16+ readonly get: (
17+ toolId: ToolId,
18+ ) => Effect.Effect<{ binding: OperationBinding; config: InvocationConfig } | null>;
19+
20+ readonly put: (
21+ toolId: ToolId,
22+ namespace: string,
23+ binding: OperationBinding,
24+ config: InvocationConfig,
25+ ) => Effect.Effect<void>;
26+
27+ readonly remove: (toolId: ToolId) => Effect.Effect<void>;
28+
29+ readonly listByNamespace: (namespace: string) => Effect.Effect<readonly ToolId[]>;
30+
31+ readonly removeByNamespace: (namespace: string) => Effect.Effect<readonly ToolId[]>;
32+
33+ readonly putSourceMeta: (meta: SourceMeta) => Effect.Effect<void>;
34+
35+ readonly removeSourceMeta: (namespace: string) => Effect.Effect<void>;
36+
37+ readonly listSourceMeta: () => Effect.Effect<readonly SourceMeta[]>;
38+}
packages/plugins/graphql/src/sdk/plugin.test.ts+195−0
@@ -0,0 +1,195 @@
1+import { describe, it, expect } from "@effect/vitest";
2+import { Effect } from "effect";
3+
4+import {
5+ createExecutor,
6+ makeTestConfig,
7+} from "@executor/sdk";
8+import { graphqlPlugin } from "./plugin";
9+import type { IntrospectionResult } from "./introspect";
10+
11+// ---------------------------------------------------------------------------
12+// Mock introspection response
13+// ---------------------------------------------------------------------------
14+
15+const introspectionResult: IntrospectionResult = {
16+ __schema: {
17+ queryType: { name: "Query" },
18+ mutationType: { name: "Mutation" },
19+ types: [
20+ {
21+ kind: "OBJECT",
22+ name: "Query",
23+ description: null,
24+ fields: [
25+ {
26+ name: "hello",
27+ description: "Say hello",
28+ args: [
29+ {
30+ name: "name",
31+ description: null,
32+ type: { kind: "SCALAR", name: "String", ofType: null },
33+ defaultValue: null,
34+ },
35+ ],
36+ type: { kind: "SCALAR", name: "String", ofType: null },
37+ },
38+ ],
39+ inputFields: null,
40+ enumValues: null,
41+ },
42+ {
43+ kind: "OBJECT",
44+ name: "Mutation",
45+ description: null,
46+ fields: [
47+ {
48+ name: "setGreeting",
49+ description: "Set greeting message",
50+ args: [
51+ {
52+ name: "message",
53+ description: null,
54+ type: {
55+ kind: "NON_NULL",
56+ name: null,
57+ ofType: { kind: "SCALAR", name: "String", ofType: null },
58+ },
59+ defaultValue: null,
60+ },
61+ ],
62+ type: { kind: "SCALAR", name: "String", ofType: null },
63+ },
64+ ],
65+ inputFields: null,
66+ enumValues: null,
67+ },
68+ { kind: "SCALAR", name: "String", description: null, fields: null, inputFields: null, enumValues: null },
69+ ],
70+ },
71+};
72+
73+describe("graphqlPlugin", () => {
74+ it("registers tools from introspection JSON", async () => {
75+ const executor = await Effect.runPromise(
76+ createExecutor(
77+ makeTestConfig({
78+ plugins: [graphqlPlugin()],
79+ }),
80+ ),
81+ );
82+
83+ const result = await Effect.runPromise(
84+ executor.graphql.addSource({
85+ endpoint: "http://localhost:4000/graphql",
86+ introspectionJson: JSON.stringify({ data: introspectionResult }),
87+ namespace: "test_api",
88+ }),
89+ );
90+
91+ expect(result.toolCount).toBe(2);
92+
93+ const tools = await Effect.runPromise(executor.tools.list());
94+ // +1 for the runtime "addSource" tool
95+ const graphqlTools = tools.filter((t) => t.pluginKey === "graphql");
96+ expect(graphqlTools).toHaveLength(3);
97+
98+ const queryTool = graphqlTools.find((t) => t.name === "query.hello");
99+ expect(queryTool).toBeDefined();
100+ expect(queryTool!.description).toBe("Say hello");
101+
102+ const mutationTool = graphqlTools.find((t) => t.name === "mutation.setGreeting");
103+ expect(mutationTool).toBeDefined();
104+ expect(mutationTool!.description).toBe("Set greeting message");
105+
106+ await Effect.runPromise(executor.close());
107+ });
108+
109+ it("removes a source and its tools", async () => {
110+ const executor = await Effect.runPromise(
111+ createExecutor(
112+ makeTestConfig({
113+ plugins: [graphqlPlugin()],
114+ }),
115+ ),
116+ );
117+
118+ await Effect.runPromise(
119+ executor.graphql.addSource({
120+ endpoint: "http://localhost:4000/graphql",
121+ introspectionJson: JSON.stringify({ data: introspectionResult }),
122+ namespace: "removable",
123+ }),
124+ );
125+
126+ let tools = await Effect.runPromise(executor.tools.list());
127+ expect(tools.filter((t) => t.pluginKey === "graphql")).toHaveLength(3);
128+
129+ await Effect.runPromise(executor.graphql.removeSource("removable"));
130+
131+ tools = await Effect.runPromise(executor.tools.list());
132+ // Runtime addSource tool remains
133+ expect(tools.filter((t) => t.pluginKey === "graphql")).toHaveLength(1);
134+
135+ await Effect.runPromise(executor.close());
136+ });
137+
138+ it("lists sources", async () => {
139+ const executor = await Effect.runPromise(
140+ createExecutor(
141+ makeTestConfig({
142+ plugins: [graphqlPlugin()],
143+ }),
144+ ),
145+ );
146+
147+ await Effect.runPromise(
148+ executor.graphql.addSource({
149+ endpoint: "http://localhost:4000/graphql",
150+ introspectionJson: JSON.stringify({ data: introspectionResult }),
151+ namespace: "my_gql",
152+ }),
153+ );
154+
155+ const sources = await Effect.runPromise(executor.sources.list());
156+ // 1 user source + 1 runtime source (executor.graphql)
157+ const gqlSources = sources.filter((s) => s.kind === "graphql");
158+ expect(gqlSources).toHaveLength(2);
159+ const userSource = gqlSources.find((s) => s.id === "my_gql");
160+ expect(userSource).toBeDefined();
161+ expect(userSource!.canRemove).toBe(true);
162+
163+ await Effect.runPromise(executor.close());
164+ });
165+
166+ it("mutations require approval", async () => {
167+ const executor = await Effect.runPromise(
168+ createExecutor(
169+ makeTestConfig({
170+ plugins: [graphqlPlugin()],
171+ }),
172+ ),
173+ );
174+
175+ await Effect.runPromise(
176+ executor.graphql.addSource({
177+ endpoint: "http://localhost:4000/graphql",
178+ introspectionJson: JSON.stringify({ data: introspectionResult }),
179+ namespace: "approval_test",
180+ }),
181+ );
182+
183+ const tools = await Effect.runPromise(executor.tools.list());
184+ const mutationTool = tools.find((t) => t.name === "mutation.setGreeting");
185+ expect(mutationTool).toBeDefined();
186+
187+ // Verify the mutation requires approval via annotations
188+ const schema = await Effect.runPromise(
189+ executor.tools.schema(mutationTool!.id),
190+ );
191+ expect(schema).toBeDefined();
192+
193+ await Effect.runPromise(executor.close());
194+ });
195+});
packages/plugins/graphql/src/sdk/plugin.ts+392−0
@@ -0,0 +1,392 @@
1+import { Effect, Option, Schema } from "effect";
2+import { FetchHttpClient, HttpClient } from "@effect/platform";
3+import type { Layer } from "effect";
4+
5+import {
6+ Source,
7+ definePlugin,
8+ registerRuntimeTools,
9+ runtimeTool,
10+ type ExecutorPlugin,
11+ type PluginContext,
12+ ToolId,
13+ type SecretId,
14+ type ToolRegistration,
15+} from "@executor/sdk";
16+
17+import { introspect, parseIntrospectionJson, type IntrospectionResult, type IntrospectionType, type IntrospectionField } from "./introspect";
18+import { extract } from "./extract";
19+import { makeGraphqlInvoker } from "./invoke";
20+import type { GraphqlOperationStore } from "./operation-store";
21+import { makeInMemoryOperationStore } from "./kv-operation-store";
22+import {
23+ ExtractedField,
24+ HeaderValue as HeaderValueSchema,
25+ InvocationConfig,
26+ OperationBinding,
27+ type HeaderValue as HeaderValueValue,
28+ type GraphqlOperationKind,
29+} from "./types";
30+
31+// ---------------------------------------------------------------------------
32+// Plugin config
33+// ---------------------------------------------------------------------------
34+
35+export type HeaderValue = HeaderValueValue;
36+
37+export interface GraphqlSourceConfig {
38+ /** The GraphQL endpoint URL */
39+ readonly endpoint: string;
40+ /** Optional: introspection JSON text (if endpoint doesn't support introspection) */
41+ readonly introspectionJson?: string;
42+ /** Namespace for the tools (derived from endpoint if not provided) */
43+ readonly namespace?: string;
44+ /** Headers applied to every request. Values can reference secrets. */
45+ readonly headers?: Record<string, HeaderValue>;
46+}
47+
48+// ---------------------------------------------------------------------------
49+// Plugin extension
50+// ---------------------------------------------------------------------------
51+
52+export interface GraphqlPluginExtension {
53+ /** Add a GraphQL endpoint and register its operations as tools */
54+ readonly addSource: (
55+ config: GraphqlSourceConfig,
56+ ) => Effect.Effect<{ readonly toolCount: number }, Error>;
57+
58+ /** Remove all tools from a previously added GraphQL source by namespace */
59+ readonly removeSource: (namespace: string) => Effect.Effect<void>;
60+}
61+
62+// ---------------------------------------------------------------------------
63+// Helpers
64+// ---------------------------------------------------------------------------
65+
66+const AddSourceInputSchema = Schema.Struct({
67+ endpoint: Schema.String,
68+ introspectionJson: Schema.optional(Schema.String),
69+ namespace: Schema.optional(Schema.String),
70+ headers: Schema.optional(
71+ Schema.Record({ key: Schema.String, value: HeaderValueSchema }),
72+ ),
73+});
74+type AddSourceInput = typeof AddSourceInputSchema.Type;
75+
76+const AddSourceOutputSchema = Schema.Struct({
77+ sourceId: Schema.String,
78+ toolCount: Schema.Number,
79+});
80+
81+/** Derive a namespace from an endpoint URL */
82+const namespaceFromEndpoint = (endpoint: string): string => {
83+ try {
84+ const url = new URL(endpoint);
85+ return url.hostname.replace(/[^a-z0-9]+/gi, "_").toLowerCase();
86+ } catch {
87+ return "graphql";
88+ }
89+};
90+
91+const buildOperationStringForField = (
92+ kind: GraphqlOperationKind,
93+ field: IntrospectionField,
94+ types: ReadonlyMap<string, IntrospectionType>,
95+): string => {
96+ const opType = kind === "query" ? "query" : "mutation";
97+
98+ const varDefs = field.args.map((arg) => {
99+ const typeName = formatTypeRef(arg.type);
100+ return `$${arg.name}: ${typeName}`;
101+ });
102+
103+ const argPasses = field.args.map((arg) => `${arg.name}: $${arg.name}`);
104+ const selectionSet = buildSelectionSet(field.type, types, 0, new Set());
105+
106+ const varDefsStr = varDefs.length > 0 ? `(${varDefs.join(", ")})` : "";
107+ const argPassStr = argPasses.length > 0 ? `(${argPasses.join(", ")})` : "";
108+
109+ return `${opType}${varDefsStr} { ${field.name}${argPassStr}${selectionSet ? ` ${selectionSet}` : ""} }`;
110+};
111+
112+import type { IntrospectionTypeRef } from "./introspect";
113+
114+const formatTypeRef = (ref: IntrospectionTypeRef): string => {
115+ switch (ref.kind) {
116+ case "NON_NULL":
117+ return ref.ofType ? `${formatTypeRef(ref.ofType)}!` : "Unknown!";
118+ case "LIST":
119+ return ref.ofType ? `[${formatTypeRef(ref.ofType)}]` : "[Unknown]";
120+ default:
121+ return ref.name ?? "Unknown";
122+ }
123+};
124+
125+const unwrapTypeName = (ref: IntrospectionTypeRef): string => {
126+ if (ref.name) return ref.name;
127+ if (ref.ofType) return unwrapTypeName(ref.ofType);
128+ return "Unknown";
129+};
130+
131+const buildSelectionSet = (
132+ ref: IntrospectionTypeRef,
133+ types: ReadonlyMap<string, IntrospectionType>,
134+ depth: number,
135+ seen: Set<string>,
136+): string => {
137+ if (depth > 2) return "";
138+
139+ const leafName = unwrapTypeName(ref);
140+ if (seen.has(leafName)) return "";
141+
142+ const objectType = types.get(leafName);
143+ if (!objectType?.fields) return "";
144+
145+ const kind = objectType.kind;
146+ if (kind === "SCALAR" || kind === "ENUM") return "";
147+
148+ seen.add(leafName);
149+
150+ const subFields = objectType.fields
151+ .filter((f) => !f.name.startsWith("__"))
152+ .slice(0, 12)
153+ .map((f) => {
154+ const sub = buildSelectionSet(f.type, types, depth + 1, seen);
155+ return sub ? `${f.name} ${sub}` : f.name;
156+ });
157+
158+ seen.delete(leafName);
159+
160+ return subFields.length > 0 ? `{ ${subFields.join(" ")} }` : "";
161+};
162+
163+const toRegistration = (
164+ field: ExtractedField,
165+ namespace: string,
166+): ToolRegistration => {
167+ const prefix = field.kind === "mutation" ? "mutation" : "query";
168+ const toolPath = `${prefix}.${field.fieldName}`;
169+ const description = Option.getOrElse(field.description, () =>
170+ `GraphQL ${field.kind}: ${field.fieldName} -> ${field.returnTypeName}`,
171+ );
172+
173+ return {
174+ id: ToolId.make(`${namespace}.${toolPath}`),
175+ pluginKey: "graphql",
176+ sourceId: namespace,
177+ name: toolPath,
178+ description,
179+ inputSchema: Option.getOrUndefined(field.inputSchema),
180+ outputSchema: undefined,
181+ };
182+};
183+
184+// ---------------------------------------------------------------------------
185+// Plugin factory
186+// ---------------------------------------------------------------------------
187+
188+export const graphqlPlugin = (options?: {
189+ readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
190+ readonly operationStore?: GraphqlOperationStore;
191+}): ExecutorPlugin<"graphql", GraphqlPluginExtension> => {
192+ const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer;
193+ const operationStore =
194+ options?.operationStore ?? makeInMemoryOperationStore();
195+
196+ return definePlugin({
197+ key: "graphql",
198+ init: (ctx: PluginContext) =>
199+ Effect.gen(function* () {
200+ yield* ctx.tools.registerInvoker(
201+ "graphql",
202+ makeGraphqlInvoker({
203+ operationStore,
204+ httpClientLayer,
205+ secrets: ctx.secrets,
206+ scopeId: ctx.scope.id,
207+ }),
208+ );
209+
210+ yield* ctx.sources.addManager({
211+ kind: "graphql",
212+
213+ list: () =>
214+ operationStore.listSourceMeta().pipe(
215+ Effect.map((metas) =>
216+ metas.map(
217+ (meta) =>
218+ new Source({
219+ id: meta.namespace,
220+ name: meta.name,
221+ kind: "graphql",
222+ runtime: false,
223+ canRemove: true,
224+ canRefresh: false,
225+ }),
226+ ),
227+ ),
228+ ),
229+
230+ remove: (sourceId: string) =>
231+ Effect.gen(function* () {
232+ yield* operationStore.removeByNamespace(sourceId);
233+ yield* operationStore.removeSourceMeta(sourceId);
234+ yield* ctx.tools.unregisterBySource(sourceId);
235+ }),
236+ });
237+
238+ const addSourceInternal = (config: GraphqlSourceConfig) =>
239+ Effect.gen(function* () {
240+ // Get introspection result — either by querying the endpoint or parsing provided JSON
241+ let introspectionResult: IntrospectionResult;
242+ if (config.introspectionJson) {
243+ introspectionResult = yield* parseIntrospectionJson(config.introspectionJson);
244+ } else {
245+ // Resolve all headers (including secret refs) for introspection
246+ const resolvedHeaders: Record<string, string> = {};
247+ if (config.headers) {
248+ for (const [name, value] of Object.entries(config.headers)) {
249+ if (typeof value === "string") {
250+ resolvedHeaders[name] = value;
251+ } else {
252+ const secret = yield* ctx.secrets
253+ .resolve(value.secretId as SecretId, ctx.scope.id)
254+ .pipe(Effect.catchAll(() => Effect.succeed("")));
255+ if (secret) {
256+ resolvedHeaders[name] = value.prefix
257+ ? `${value.prefix}${secret}`
258+ : secret;
259+ }
260+ }
261+ }
262+ }
263+
264+ introspectionResult = yield* introspect(
265+ config.endpoint,
266+ Object.keys(resolvedHeaders).length > 0 ? resolvedHeaders : undefined,
267+ ).pipe(Effect.provide(httpClientLayer));
268+ }
269+
270+ const { result, definitions } = yield* extract(introspectionResult);
271+ const namespace = config.namespace ?? namespaceFromEndpoint(config.endpoint);
272+
273+ // Register shared JSON Schema definitions ($ref targets)
274+ if (Object.keys(definitions).length > 0) {
275+ yield* ctx.tools.registerDefinitions(definitions);
276+ }
277+
278+ const invocationConfig = new InvocationConfig({
279+ endpoint: config.endpoint,
280+ headers: config.headers ?? {},
281+ });
282+
283+ // Build type map for operation string generation
284+ const typeMap = new Map<string, IntrospectionType>();
285+ for (const t of introspectionResult.__schema.types) {
286+ typeMap.set(t.name, t);
287+ }
288+
289+ // Build field map for operation strings
290+ const fieldMap = new Map<string, { kind: GraphqlOperationKind; field: IntrospectionField }>();
291+ const schema = introspectionResult.__schema;
292+
293+ for (const rootKind of ["query", "mutation"] as const) {
294+ const typeName = rootKind === "query"
295+ ? schema.queryType?.name
296+ : schema.mutationType?.name;
297+ if (!typeName) continue;
298+ const rootType = typeMap.get(typeName);
299+ if (!rootType?.fields) continue;
300+ for (const f of rootType.fields) {
301+ if (!f.name.startsWith("__")) {
302+ fieldMap.set(`${rootKind}.${f.name}`, { kind: rootKind, field: f });
303+ }
304+ }
305+ }
306+
307+ const registrations: ToolRegistration[] = [];
308+
309+ yield* Effect.forEach(
310+ result.fields,
311+ (extractedField) => {
312+ const reg = toRegistration(extractedField, namespace);
313+ registrations.push(reg);
314+
315+ const key = `${extractedField.kind}.${extractedField.fieldName}`;
316+ const entry = fieldMap.get(key);
317+
318+ const operationString = entry
319+ ? buildOperationStringForField(entry.kind, entry.field, typeMap)
320+ : `${extractedField.kind} { ${extractedField.fieldName} }`;
321+
322+ const binding = new OperationBinding({
323+ kind: extractedField.kind,
324+ fieldName: extractedField.fieldName,
325+ operationString,
326+ variableNames: extractedField.arguments.map((a) => a.name),
327+ });
328+
329+ return operationStore.put(reg.id, namespace, binding, invocationConfig);
330+ },
331+ { discard: true },
332+ );
333+
334+ yield* ctx.tools.register(registrations);
335+
336+ yield* operationStore.putSourceMeta({
337+ namespace,
338+ name: namespace,
339+ });
340+
341+ return { sourceId: namespace, toolCount: registrations.length };
342+ });
343+
344+ const runtimeTools = yield* registerRuntimeTools({
345+ registry: ctx.tools,
346+ sources: ctx.sources,
347+ pluginKey: "graphql",
348+ source: {
349+ id: "executor.graphql",
350+ name: "GraphQL",
351+ },
352+ tools: [
353+ runtimeTool({
354+ id: "executor.graphql.addSource",
355+ name: "addSource",
356+ description:
357+ "Add a GraphQL endpoint and register its operations as tools",
358+ inputSchema: AddSourceInputSchema,
359+ outputSchema: AddSourceOutputSchema,
360+ handler: (input: AddSourceInput) => addSourceInternal(input),
361+ }),
362+ ],
363+ });
364+
365+ return {
366+ extension: {
367+ addSource: (config: GraphqlSourceConfig) =>
368+ addSourceInternal(config).pipe(
369+ Effect.map(({ toolCount }) => ({ toolCount })),
370+ Effect.catchAll((err) =>
371+ Effect.fail(
372+ err instanceof Error ? err : new Error(String(err)),
373+ ),
374+ ),
375+ ),
376+
377+ removeSource: (namespace: string) =>
378+ Effect.gen(function* () {
379+ const toolIds =
380+ yield* operationStore.removeByNamespace(namespace);
381+ if (toolIds.length > 0) {
382+ yield* ctx.tools.unregister(toolIds);
383+ }
384+ yield* operationStore.removeSourceMeta(namespace);
385+ }),
386+ },
387+
388+ close: () => runtimeTools.close(),
389+ };
390+ }),
391+ });
392+};
packages/plugins/graphql/src/sdk/types.ts+92−0
@@ -0,0 +1,92 @@
1+import { Schema } from "effect";
2+
3+// ---------------------------------------------------------------------------
4+// GraphQL operation kind
5+// ---------------------------------------------------------------------------
6+
7+export const GraphqlOperationKind = Schema.Literal("query", "mutation");
8+export type GraphqlOperationKind = typeof GraphqlOperationKind.Type;
9+
10+// ---------------------------------------------------------------------------
11+// Extracted field (becomes a tool)
12+// ---------------------------------------------------------------------------
13+
14+export class GraphqlArgument extends Schema.Class<GraphqlArgument>(
15+ "GraphqlArgument",
16+)({
17+ name: Schema.String,
18+ typeName: Schema.String,
19+ required: Schema.Boolean,
20+ description: Schema.optionalWith(Schema.String, { as: "Option" }),
21+}) {}
22+
23+export class ExtractedField extends Schema.Class<ExtractedField>(
24+ "ExtractedField",
25+)({
26+ /** e.g. "user", "createUser" */
27+ fieldName: Schema.String,
28+ /** "query" or "mutation" */
29+ kind: GraphqlOperationKind,
30+ description: Schema.optionalWith(Schema.String, { as: "Option" }),
31+ arguments: Schema.Array(GraphqlArgument),
32+ /** JSON Schema for the input (built from arguments) */
33+ inputSchema: Schema.optionalWith(Schema.Unknown, { as: "Option" }),
34+ /** The return type name for documentation */
35+ returnTypeName: Schema.String,
36+}) {}
37+
38+export class ExtractionResult extends Schema.Class<ExtractionResult>(
39+ "ExtractionResult",
40+)({
41+ /** Schema name from introspection */
42+ schemaName: Schema.optionalWith(Schema.String, { as: "Option" }),
43+ fields: Schema.Array(ExtractedField),
44+}) {}
45+
46+// ---------------------------------------------------------------------------
47+// Operation binding — minimal data needed to invoke
48+// ---------------------------------------------------------------------------
49+
50+export class OperationBinding extends Schema.Class<OperationBinding>(
51+ "OperationBinding",
52+)({
53+ kind: GraphqlOperationKind,
54+ fieldName: Schema.String,
55+ /** The full GraphQL query/mutation string */
56+ operationString: Schema.String,
57+ /** Ordered variable names for mapping */
58+ variableNames: Schema.Array(Schema.String),
59+}) {}
60+
61+// ---------------------------------------------------------------------------
62+// Invocation
63+// ---------------------------------------------------------------------------
64+
65+export const HeaderValue = Schema.Union(
66+ Schema.String,
67+ Schema.Struct({
68+ secretId: Schema.String,
69+ prefix: Schema.optional(Schema.String),
70+ }),
71+);
72+export type HeaderValue = typeof HeaderValue.Type;
73+
74+export class InvocationConfig extends Schema.Class<InvocationConfig>(
75+ "InvocationConfig",
76+)({
77+ /** The GraphQL endpoint URL */
78+ endpoint: Schema.String,
79+ /** Headers applied to every request. Values can reference secrets. */
80+ headers: Schema.optionalWith(
81+ Schema.Record({ key: Schema.String, value: HeaderValue }),
82+ { default: () => ({}) },
83+ ),
84+}) {}
85+
86+export class InvocationResult extends Schema.Class<InvocationResult>(
87+ "InvocationResult",
88+)({
89+ status: Schema.Number,
90+ data: Schema.NullOr(Schema.Unknown),
91+ errors: Schema.NullOr(Schema.Unknown),
92+}) {}
packages/plugins/graphql/tsconfig.json+27−0
@@ -0,0 +1,27 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "Bundler",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "noEmit": true,
9+ "lib": ["ES2022", "DOM"],
10+ "types": ["bun-types", "node"],
11+ "noUnusedLocals": true,
12+ "noImplicitOverride": true,
13+ "jsx": "react-jsx",
14+ "plugins": [
15+ {
16+ "name": "@effect/language-service",
17+ "diagnostics": {
18+ "preferSchemaOverJson": "off"
19+ }
20+ }
21+ ]
22+ },
23+ "include": [
24+ "src/**/*.ts",
25+ "src/**/*.tsx"
26+ ]
27+}
packages/plugins/openapi/src/sdk/real-specs.test.ts+1−1
@@ -153,7 +153,7 @@ describe("Real specs: Cloudflare API", () => { · 152 unmodified lines
153153 const output = schema.outputSchema as Record<string, unknown>;
154154
155155 expect(output["$ref"]).toBeTypeOf("string");
156− expect((output["$ref"] as string).startsWith("#/components/schemas/")).toBe(true);
156+ expect((output["$ref"] as string).startsWith("#/$defs/")).toBe(true);
157157
158158 expect(output["$defs"]).toBeDefined();
159159 const defs = output["$defs"] as Record<string, unknown>;