diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bca6f2b..efc3e87 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,4 +21,5 @@ jobs:
run: npm run lint
- run: npm run typecheck
- run: npm test
+ - run: npm run test:coverage:check
- run: npm run build
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9f1f718..274bf65 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -33,9 +33,12 @@ jobs:
- name: Upgrade npm (OIDC trusted publishing needs npm >= 11.5; pin 11)
run: npm install -g npm@11
- name: Install
- run: npm ci || npm install
- - run: npm run build --if-present
+ run: npm ci
+ - run: npm run lint
+ - run: npm run typecheck
- run: npm test
+ - run: npm run test:coverage:check
+ - run: npm run build
- name: Configure git identity
run: |
git config user.name "Mark Stuart"
diff --git a/README.md b/README.md
index ea06f68..08548b6 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
-
+
@@ -39,7 +39,7 @@ npm install graphql-agent-toolkit graphql
## Requirements
-- Node.js >= 18.0.0
+- Node.js >= 22.0.0
- `graphql` >= 16.0.0 (peer dependency)
- TypeScript >= 5.0 (optional, for type definitions)
@@ -67,7 +67,7 @@ import { fetchSchema, parseSchema } from 'graphql-agent-toolkit';
const introspection = await fetchSchema({
endpoint: 'https://your-api.com/graphql',
- headers: { Authorization: 'Bearer YOUR_TOKEN' },
+ headers: { Authorization: `Bearer ${process.env.GRAPHQL_AUTH_TOKEN}` },
});
const schema = parseSchema(introspection);
@@ -109,7 +109,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
const server = await createAgentToolkitServer({
endpoint: 'https://your-api.com/graphql',
- headers: { Authorization: 'Bearer YOUR_TOKEN' },
+ headers: { Authorization: `Bearer ${process.env.GRAPHQL_AUTH_TOKEN}` },
operationDepth: 2,
});
@@ -262,16 +262,29 @@ graphql-agent-toolkit init \
--output config.json
```
+`init` uses literal headers only for the introspection request. Generated config replaces header values with environment placeholders such as `Bearer ${GRAPHQL_AGENT_AUTHORIZATION}` so auth secrets are not persisted.
+
### `serve` -- Start MCP server
```bash
# From a config file
+export GRAPHQL_AGENT_AUTHORIZATION=your-token
graphql-agent-toolkit serve --config config.json
# Directly from an endpoint
graphql-agent-toolkit serve --endpoint https://your-api.com/graphql
```
+### Runnable examples
+
+```bash
+export GRAPHQL_ENDPOINT=https://your-api.com/graphql
+export GRAPHQL_AUTH_TOKEN=your-token # omit for public APIs
+
+node examples/langchain-structured.mjs
+node examples/mcp-server.mjs
+```
+
## MCP Server Usage
Add to your MCP client configuration (e.g., Claude Desktop):
@@ -281,7 +294,10 @@ Add to your MCP client configuration (e.g., Claude Desktop):
"mcpServers": {
"my-graphql-api": {
"command": "npx",
- "args": ["graphql-agent-toolkit", "serve", "--endpoint", "https://your-api.com/graphql"]
+ "env": {
+ "GRAPHQL_AGENT_AUTHORIZATION": "your-token"
+ },
+ "args": ["graphql-agent-toolkit", "serve", "--config", "/absolute/path/to/config.json"]
}
}
}
@@ -363,9 +379,7 @@ The `AgentToolkitConfig` object accepts:
1. Clone the repository
2. Install dependencies: `npm install`
-3. Run tests: `npm test`
-4. Build: `npm run build`
-5. Lint: `npm run lint`
+3. Run lint/typecheck/tests/coverage/build: `npm run lint && npm run typecheck && npm test && npm run test:coverage:check && npm run build`
## License
diff --git a/examples/langchain-structured.mjs b/examples/langchain-structured.mjs
new file mode 100644
index 0000000..fed6320
--- /dev/null
+++ b/examples/langchain-structured.mjs
@@ -0,0 +1,21 @@
+import { GraphQLClient } from 'graphql-request';
+import { createStructuredTools, fetchSchema, parseSchema } from 'graphql-agent-toolkit';
+
+const endpoint = process.env.GRAPHQL_ENDPOINT;
+
+if (!endpoint) {
+ throw new Error('Set GRAPHQL_ENDPOINT before running this example.');
+}
+
+const token = process.env.GRAPHQL_AUTH_TOKEN;
+const headers = token ? { Authorization: `Bearer ${token}` } : undefined;
+const introspection = await fetchSchema({ endpoint, headers });
+const schema = parseSchema(introspection);
+const client = new GraphQLClient(endpoint, { headers });
+
+const tools = createStructuredTools(schema, {
+ execute: async (operation, variables) =>
+ JSON.stringify(await client.request(operation, variables)),
+});
+
+console.log(tools.map((tool) => tool.name).join('\n'));
diff --git a/examples/mcp-server.mjs b/examples/mcp-server.mjs
new file mode 100644
index 0000000..07dad17
--- /dev/null
+++ b/examples/mcp-server.mjs
@@ -0,0 +1,18 @@
+import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
+import { createAgentToolkitServer } from 'graphql-agent-toolkit';
+
+const endpoint = process.env.GRAPHQL_ENDPOINT;
+
+if (!endpoint) {
+ throw new Error('Set GRAPHQL_ENDPOINT before running this example.');
+}
+
+const token = process.env.GRAPHQL_AUTH_TOKEN;
+
+const server = await createAgentToolkitServer({
+ endpoint,
+ headers: token ? { Authorization: `Bearer ${token}` } : undefined,
+ operationDepth: 2,
+});
+
+await server.connect(new StdioServerTransport());
diff --git a/package-lock.json b/package-lock.json
index a399eb4..036ab45 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,6 +22,7 @@
"@types/node": "^26.1.2",
"@typescript-eslint/eslint-plugin": "^8.66.0",
"@typescript-eslint/parser": "^8.66.0",
+ "@vitest/coverage-v8": "^4.1.10",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.5",
@@ -81,6 +82,16 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
@@ -91,6 +102,22 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/parser": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
@@ -101,6 +128,30 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@cacheable/memory": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz",
@@ -3529,6 +3580,37 @@
"win32"
]
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
+ "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.1.10",
+ "ast-v8-to-istanbul": "^1.0.0",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.2",
+ "obug": "^2.1.1",
+ "std-env": "^4.0.0-rc.1",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.1.10",
+ "vitest": "4.1.10"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
@@ -3944,6 +4026,25 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz",
+ "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/astral-regex": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
@@ -7134,6 +7235,13 @@
],
"license": "MIT"
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/html-tags": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/html-tags/-/html-tags-5.1.0.tgz",
@@ -7903,6 +8011,68 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-report/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/jose": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
@@ -8513,6 +8683,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/magicast": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz",
+ "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "source-map-js": "^1.2.1"
+ }
+ },
"node_modules/make-asynchronous": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.1.0.tgz",
@@ -8531,6 +8713,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
diff --git a/package.json b/package.json
index eecff4a..2ab6f37 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "graphql-agent-toolkit",
"version": "1.0.0",
- "description": "Turn any GraphQL API into AI-agent-ready tools \u2014 MCP servers, LangChain tools, and standalone SDKs",
+ "description": "Turn any GraphQL API into AI-agent-ready tools — MCP servers, LangChain tools, and standalone SDKs",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
@@ -22,12 +22,15 @@
"graphql-agent-toolkit": "./dist/cli.js"
},
"files": [
- "dist"
+ "dist",
+ "examples"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest run",
+ "test:coverage": "vitest run --coverage",
+ "test:coverage:check": "vitest run --coverage --coverage.thresholds.lines=60 --coverage.thresholds.functions=60 --coverage.thresholds.branches=60 --coverage.thresholds.statements=60",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run build",
@@ -37,14 +40,19 @@
},
"keywords": [
"graphql",
+ "graphql-mcp-server",
"mcp",
+ "mcp-server",
"ai",
"agent",
+ "agent-tools",
"toolkit",
"langchain",
"model-context-protocol",
+ "graphql-tools",
"crewai",
"vercel-ai",
+ "zod",
"introspection",
"typescript",
"sdk"
@@ -94,6 +102,7 @@
"eslint-plugin-unicorn": "^72.0.0",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^17.9.0",
+ "@vitest/coverage-v8": "^4.1.10",
"graphql": "^16.12.0",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
diff --git a/src/adapters/langchain.ts b/src/adapters/langchain.ts
index 4f1914e..0d55821 100644
--- a/src/adapters/langchain.ts
+++ b/src/adapters/langchain.ts
@@ -1,5 +1,6 @@
import { z } from 'zod';
import { buildOperation } from '../operations/index.js';
+import { typeReferenceToZod } from '../zod.js';
import { unwrapType } from '../operations/variables.js';
import type {
ParsedSchema,
@@ -32,8 +33,6 @@ type JsonSchemaConverter = (
schema: ParsedSchema,
) => Record;
-type ZodSchemaConverter = (typeReference: TypeReference, schema: ParsedSchema) => z.ZodType;
-
const namedTypeToJsonSchema = (
namedType: SchemaType,
schema: ParsedSchema,
@@ -62,28 +61,6 @@ const namedTypeToJsonSchema = (
return result;
};
-const namedTypeToZod = (
- namedType: SchemaType,
- schema: ParsedSchema,
- convertType: ZodSchemaConverter,
-): z.ZodType | undefined => {
- if (namedType.kind === 'ENUM' && namedType.enumValues.length > 0) {
- const values = namedType.enumValues.map((value) => value.name) as [string, ...string[]];
- return z.enum(values).optional();
- }
-
- if (namedType.kind !== 'INPUT_OBJECT') {
- return undefined;
- }
-
- const shape: Record = {};
- for (const field of namedType.inputFields) {
- const fieldSchema = convertType(field.type, schema);
- shape[field.name] = field.type.kind === 'NON_NULL' ? fieldSchema : fieldSchema.optional();
- }
- return z.object(shape);
-};
-
/**
* Convert a GraphQL TypeRef to a JSON Schema representation.
*/
@@ -138,57 +115,6 @@ const typeReferenceToJsonSchema = (
}
};
-/**
- * Convert a GraphQL TypeRef to a Zod schema.
- */
-const typeReferenceToZod = (typeReference: TypeReference, schema: ParsedSchema): z.ZodType => {
- if (typeReference.kind === 'NON_NULL') {
- if (!typeReference.ofType) {
- return z.unknown();
- }
- return typeReferenceToZod(typeReference.ofType, schema);
- }
-
- if (typeReference.kind === 'LIST') {
- if (!typeReference.ofType) {
- return z.array(z.unknown()).optional();
- }
- return z.array(typeReferenceToZod(typeReference.ofType, schema)).optional();
- }
-
- const unwrapped = unwrapType(typeReference);
- const typeName = unwrapped.name;
-
- if (typeName) {
- const namedType = schema.types.get(typeName);
- if (namedType) {
- const namedSchema = namedTypeToZod(namedType, schema, typeReferenceToZod);
- if (namedSchema) {
- return namedSchema;
- }
- }
- }
-
- switch (typeName) {
- case 'String':
- case 'ID': {
- return z.string().optional();
- }
- case 'Int': {
- return z.number().int().optional();
- }
- case 'Float': {
- return z.number().optional();
- }
- case 'Boolean': {
- return z.boolean().optional();
- }
- default: {
- return z.unknown().optional();
- }
- }
-};
-
/**
* Build JSON Schema for a field's arguments.
*/
@@ -223,8 +149,7 @@ const buildZodSchema = (
const shape: Record = {};
for (const argument of field.args) {
- const zodType = typeReferenceToZod(argument.type, schema);
- shape[argument.name] = argument.type.kind === 'NON_NULL' ? zodType : zodType.optional();
+ shape[argument.name] = typeReferenceToZod(argument.type, schema);
}
return z.object(shape);
diff --git a/src/adapters/vercel-ai.ts b/src/adapters/vercel-ai.ts
index a87ae3b..635a621 100644
--- a/src/adapters/vercel-ai.ts
+++ b/src/adapters/vercel-ai.ts
@@ -1,12 +1,7 @@
import { z } from 'zod';
import { buildOperation } from '../operations/index.js';
-import { unwrapType } from '../operations/variables.js';
-import type {
- ParsedSchema,
- SchemaField,
- SchemaType,
- TypeRef as TypeReference,
-} from '../types/index.js';
+import { typeReferenceToZod } from '../zod.js';
+import type { ParsedSchema, SchemaField } from '../types/index.js';
import type { GraphQLExecutor } from '../mcp/executor.js';
export interface VercelAIToolConfig {
@@ -19,81 +14,6 @@ interface AdapterOptions {
maxDepth?: number;
}
-type ZodSchemaConverter = (typeReference: TypeReference, schema: ParsedSchema) => z.ZodType;
-
-const namedTypeToZod = (
- namedType: SchemaType,
- schema: ParsedSchema,
- convertType: ZodSchemaConverter,
-): z.ZodType | undefined => {
- if (namedType.kind === 'ENUM' && namedType.enumValues.length > 0) {
- const values = namedType.enumValues.map((value) => value.name) as [string, ...string[]];
- return z.enum(values).optional();
- }
-
- if (namedType.kind !== 'INPUT_OBJECT') {
- return undefined;
- }
-
- const shape: Record = {};
- for (const field of namedType.inputFields) {
- const fieldSchema = convertType(field.type, schema);
- shape[field.name] = field.type.kind === 'NON_NULL' ? fieldSchema : fieldSchema.optional();
- }
- return z.object(shape);
-};
-
-/**
- * Convert a GraphQL TypeRef to a Zod schema.
- */
-const typeReferenceToZod = (typeReference: TypeReference, schema: ParsedSchema): z.ZodType => {
- if (typeReference.kind === 'NON_NULL') {
- if (!typeReference.ofType) {
- return z.unknown();
- }
- return typeReferenceToZod(typeReference.ofType, schema);
- }
-
- if (typeReference.kind === 'LIST') {
- if (!typeReference.ofType) {
- return z.array(z.unknown()).optional();
- }
- return z.array(typeReferenceToZod(typeReference.ofType, schema)).optional();
- }
-
- const unwrapped = unwrapType(typeReference);
- const typeName = unwrapped.name;
-
- if (typeName) {
- const namedType = schema.types.get(typeName);
- if (namedType) {
- const namedSchema = namedTypeToZod(namedType, schema, typeReferenceToZod);
- if (namedSchema) {
- return namedSchema;
- }
- }
- }
-
- switch (typeName) {
- case 'String':
- case 'ID': {
- return z.string().optional();
- }
- case 'Int': {
- return z.number().int().optional();
- }
- case 'Float': {
- return z.number().optional();
- }
- case 'Boolean': {
- return z.boolean().optional();
- }
- default: {
- return z.unknown().optional();
- }
- }
-};
-
/**
* Build a Zod object schema for a field's arguments.
*/
@@ -104,8 +24,7 @@ const buildParametersSchema = (
const shape: Record = {};
for (const argument of field.args) {
- const zodType = typeReferenceToZod(argument.type, schema);
- shape[argument.name] = argument.type.kind === 'NON_NULL' ? zodType : zodType.optional();
+ shape[argument.name] = typeReferenceToZod(argument.type, schema);
}
return z.object(shape);
diff --git a/src/cli/init.ts b/src/cli/init.ts
index 1cb1354..2fc2465 100644
--- a/src/cli/init.ts
+++ b/src/cli/init.ts
@@ -30,6 +30,26 @@ const parseHeaders = (headerArguments?: string[]): Record => {
return headers;
};
+const environmentNameForHeader = (key: string): string => {
+ const normalized = key
+ .toUpperCase()
+ .split(/[^A-Z0-9]+/u)
+ .filter(Boolean)
+ .join('_');
+ return `GRAPHQL_AGENT_${normalized || 'HEADER'}`;
+};
+
+const sanitizeHeaderValue = (key: string, value: string): string => {
+ const environmentName = environmentNameForHeader(key);
+ const bearer = value.toLowerCase().startsWith('bearer ');
+ return bearer ? `Bearer \${${environmentName}}` : `\${${environmentName}}`;
+};
+
+const sanitizeHeadersForConfig = (headers: Record): Record =>
+ Object.fromEntries(
+ Object.entries(headers).map(([key, value]) => [key, sanitizeHeaderValue(key, value)]),
+ );
+
const initialize = async (options: InitOptions): Promise => {
const headers = parseHeaders(options.header);
@@ -57,9 +77,16 @@ const initialize = async (options: InitOptions): Promise =>
console.log(` Queries: ${queryCount}`);
console.log(` Mutations: ${mutationCount}`);
+ const safeHeaders = sanitizeHeadersForConfig(headers);
+ if (Object.keys(headers).length > 0) {
+ console.warn(
+ 'Warning: header values were used for introspection but replaced with environment placeholders in generated config.',
+ );
+ }
+
const config: AgentToolkitConfig = {
endpoint: options.endpoint,
- ...(Object.keys(headers).length > 0 && { headers }),
+ ...(Object.keys(safeHeaders).length > 0 && { headers: safeHeaders }),
includeDeprecated: false,
operationDepth: 2,
};
diff --git a/src/cli/serve.ts b/src/cli/serve.ts
index 325aada..5f3d9d9 100644
--- a/src/cli/serve.ts
+++ b/src/cli/serve.ts
@@ -25,6 +25,40 @@ const parseHeaders = (headerValues?: string[]): Record => {
return headers;
};
+const expandEnvironmentPlaceholders = (value: string): string =>
+ value.replaceAll(
+ /\$\{(?[A-Z_][A-Z0-9_]*)\}|\$(?[A-Z_][A-Z0-9_]*)/giu,
+ (match, ...matches) => {
+ const groups = matches.at(-1) as { bare?: string; braced?: string } | undefined;
+ const name = groups?.braced || groups?.bare;
+ if (!name) {
+ return match;
+ }
+ const environmentValue = process.env[name];
+ if (environmentValue === undefined) {
+ console.error(`Warning: environment variable ${name} is not set for configured header.`);
+ return match;
+ }
+ return environmentValue;
+ },
+ );
+
+const resolveHeaderEnvironment = (config: AgentToolkitConfig): AgentToolkitConfig => {
+ if (!config.headers) {
+ return config;
+ }
+
+ return {
+ ...config,
+ headers: Object.fromEntries(
+ Object.entries(config.headers).map(([key, value]) => [
+ key,
+ expandEnvironmentPlaceholders(value),
+ ]),
+ ),
+ };
+};
+
export const runServe = async (options: ServeOptions): Promise => {
let config: AgentToolkitConfig;
@@ -48,6 +82,8 @@ export const runServe = async (options: ServeOptions): Promise => {
process.exit(1);
}
+ config = resolveHeaderEnvironment(config);
+
console.error(`Starting MCP server for endpoint: ${config.endpoint}`);
try {
diff --git a/src/mcp/tool-factory.ts b/src/mcp/tool-factory.ts
index d27fcd7..d3c273b 100644
--- a/src/mcp/tool-factory.ts
+++ b/src/mcp/tool-factory.ts
@@ -1,12 +1,7 @@
-import { z } from 'zod';
import { buildOperation } from '../operations/index.js';
-import { unwrapType } from '../operations/variables.js';
-import type {
- ParsedSchema,
- SchemaField,
- SchemaType,
- TypeRef as TypeReference,
-} from '../types/index.js';
+import { typeReferenceToZod } from '../zod.js';
+import type { z } from 'zod';
+import type { ParsedSchema, SchemaField } from '../types/index.js';
import type { GraphQLExecutor } from './executor.js';
export interface McpToolDefinition {
@@ -21,84 +16,6 @@ export interface CreateToolsOptions {
includeDeprecated?: boolean;
}
-type ZodSchemaConverter = (typeReference: TypeReference, schema: ParsedSchema) => z.ZodType;
-
-const namedTypeToZod = (
- namedType: SchemaType,
- schema: ParsedSchema,
- convertType: ZodSchemaConverter,
-): z.ZodType | undefined => {
- if (namedType.kind === 'ENUM' && namedType.enumValues.length > 0) {
- const values = namedType.enumValues.map((value) => value.name) as [string, ...string[]];
- return z.enum(values).optional();
- }
-
- if (namedType.kind !== 'INPUT_OBJECT') {
- return undefined;
- }
-
- const shape: Record = {};
- for (const field of namedType.inputFields) {
- const fieldSchema = convertType(field.type, schema);
- shape[field.name] = field.type.kind === 'NON_NULL' ? fieldSchema : fieldSchema.optional();
- }
- return z.object(shape);
-};
-
-/**
- * Maps a GraphQL TypeRef to a Zod schema for validation.
- */
-const typeReferenceToZod = (typeReference: TypeReference, schema: ParsedSchema): z.ZodType => {
- if (typeReference.kind === 'NON_NULL') {
- if (!typeReference.ofType) {
- return z.unknown();
- }
- return typeReferenceToZod(typeReference.ofType, schema);
- }
-
- if (typeReference.kind === 'LIST') {
- if (!typeReference.ofType) {
- return z.array(z.unknown());
- }
- return z.array(typeReferenceToZod(typeReference.ofType, schema)).optional();
- }
-
- const unwrapped = unwrapType(typeReference);
- const typeName = unwrapped.name;
-
- if (typeName) {
- const namedType = schema.types.get(typeName);
- if (namedType) {
- const namedSchema = namedTypeToZod(namedType, schema, typeReferenceToZod);
- if (namedSchema) {
- return namedSchema;
- }
- }
- }
-
- // Map scalars
- switch (typeName) {
- case 'String': {
- return z.string().optional();
- }
- case 'Int': {
- return z.number().int().optional();
- }
- case 'Float': {
- return z.number().optional();
- }
- case 'Boolean': {
- return z.boolean().optional();
- }
- case 'ID': {
- return z.string().optional();
- }
- default: {
- return z.unknown().optional();
- }
- }
-};
-
/**
* Builds the Zod input schema object for a tool from a field's arguments.
*/
@@ -106,8 +23,7 @@ const buildInputSchema = (field: SchemaField, schema: ParsedSchema): Record = {};
for (const argument of field.args) {
- const zodType = typeReferenceToZod(argument.type, schema);
- shape[argument.name] = argument.type.kind === 'NON_NULL' ? zodType : zodType.optional();
+ shape[argument.name] = typeReferenceToZod(argument.type, schema);
}
return shape;
diff --git a/src/zod.ts b/src/zod.ts
new file mode 100644
index 0000000..7df9cf2
--- /dev/null
+++ b/src/zod.ts
@@ -0,0 +1,68 @@
+import { z } from 'zod';
+import { unwrapType } from './operations/variables.js';
+import type { ParsedSchema, TypeRef as TypeReference } from './types/index.js';
+
+const withGraphQLNullability = (zodType: z.ZodType, isNullable: boolean): z.ZodType =>
+ isNullable ? zodType.nullish() : zodType;
+
+/**
+ * Maps a GraphQL TypeRef to a Zod schema while preserving GraphQL nullability.
+ * Nullable GraphQL values accept null or omission. NON_NULL values reject both.
+ */
+export const typeReferenceToZod = (
+ typeReference: TypeReference,
+ schema: ParsedSchema,
+ isNullable = true,
+): z.ZodType => {
+ if (typeReference.kind === 'NON_NULL') {
+ if (!typeReference.ofType) {
+ return z.unknown();
+ }
+ return typeReferenceToZod(typeReference.ofType, schema, false);
+ }
+
+ if (typeReference.kind === 'LIST') {
+ const itemSchema = typeReference.ofType
+ ? typeReferenceToZod(typeReference.ofType, schema)
+ : z.unknown();
+ return withGraphQLNullability(z.array(itemSchema), isNullable);
+ }
+
+ const unwrapped = unwrapType(typeReference);
+ const typeName = unwrapped.name;
+
+ if (typeName) {
+ const namedType = schema.types.get(typeName);
+ if (namedType && namedType.kind === 'ENUM' && namedType.enumValues.length > 0) {
+ const values = namedType.enumValues.map((value) => value.name) as [string, ...string[]];
+ return withGraphQLNullability(z.enum(values), isNullable);
+ }
+
+ if (namedType && namedType.kind === 'INPUT_OBJECT') {
+ const shape: Record = {};
+ for (const field of namedType.inputFields) {
+ shape[field.name] = typeReferenceToZod(field.type, schema);
+ }
+ return withGraphQLNullability(z.object(shape), isNullable);
+ }
+ }
+
+ switch (typeName) {
+ case 'String':
+ case 'ID': {
+ return withGraphQLNullability(z.string(), isNullable);
+ }
+ case 'Int': {
+ return withGraphQLNullability(z.number().int(), isNullable);
+ }
+ case 'Float': {
+ return withGraphQLNullability(z.number(), isNullable);
+ }
+ case 'Boolean': {
+ return withGraphQLNullability(z.boolean(), isNullable);
+ }
+ default: {
+ return withGraphQLNullability(z.unknown(), isNullable);
+ }
+ }
+};
diff --git a/tests/adapters/langchain.test.ts b/tests/adapters/langchain.test.ts
index 7cf678d..0d09c41 100644
--- a/tests/adapters/langchain.test.ts
+++ b/tests/adapters/langchain.test.ts
@@ -215,6 +215,23 @@ describe('createStructuredTools', () => {
expect(result).toContain('Alice');
});
+ it('should reject missing or null GraphQL NON_NULL args and accept nullable null args', () => {
+ const schema = buildTestSchema();
+ const executor = createMockExecutor();
+ const tools = createStructuredTools(schema, executor);
+
+ const userTool = tools.find((t) => t.name === 'query_user');
+ assert.ok(userTool);
+ expect(userTool.schema.parse({ id: '1' }).id).toBe('1');
+ expect(() => userTool.schema.parse({})).toThrow();
+ expect(() => userTool.schema.parse({ id: null })).toThrow();
+
+ const usersTool = tools.find((t) => t.name === 'query_users');
+ assert.ok(usersTool);
+ expect(usersTool.schema.parse({})).toEqual({});
+ expect(usersTool.schema.parse({ limit: null }).limit).toBeNull();
+ });
+
it('should validate input with Zod schema', () => {
const schema = buildTestSchema();
const executor = createMockExecutor();
diff --git a/tests/adapters/vercel-ai.test.ts b/tests/adapters/vercel-ai.test.ts
index 7ea4870..63bedc0 100644
--- a/tests/adapters/vercel-ai.test.ts
+++ b/tests/adapters/vercel-ai.test.ts
@@ -168,6 +168,21 @@ describe('createVercelAITools', () => {
expect(parsedWithLimit.limit).toBe(10);
});
+ it('should reject missing or null GraphQL NON_NULL args and accept nullable null args', () => {
+ const schema = buildTestSchema();
+ const executor = createMockExecutor();
+ const tools = createVercelAITools(schema, executor);
+
+ const userTool = tools['query_user'];
+ expect(userTool.parameters.parse({ id: '123' }).id).toBe('123');
+ expect(() => userTool.parameters.parse({})).toThrow();
+ expect(() => userTool.parameters.parse({ id: null })).toThrow();
+
+ const searchTool = tools['query_search'];
+ expect(searchTool.parameters.parse({ query: 'test' })).toEqual({ query: 'test' });
+ expect(searchTool.parameters.parse({ limit: null, query: 'test' }).limit).toBeNull();
+ });
+
it('should execute operations via the executor', async () => {
const schema = buildTestSchema();
const executor = createMockExecutor();
diff --git a/tests/cli/init.test.ts b/tests/cli/init.test.ts
index 93ce4d9..fdf0801 100644
--- a/tests/cli/init.test.ts
+++ b/tests/cli/init.test.ts
@@ -36,15 +36,33 @@ describe('runInit', () => {
expect(config.includeDeprecated).toBe(false);
});
- it('should include headers in config when provided', async () => {
+ it('should replace literal header values with env placeholders in generated config', async () => {
const { runInit } = await import(/* webpackChunkName: "cli-init" */ '../../src/cli/init.js');
+ const environmentName = 'GRAPHQL_AGENT_AUTHORIZATION';
+ const authorizationPlaceholder = `Bearer \${${environmentName}}`;
const config = await runInit({
endpoint: 'https://example.com/graphql',
header: ['Authorization: Bearer test123'],
});
- expect(config.headers).toEqual({ Authorization: 'Bearer test123' });
+ expect(config.headers).toEqual({ Authorization: authorizationPlaceholder });
+ });
+
+ it('should not write literal auth secrets to config files', async () => {
+ const { runInit } = await import(/* webpackChunkName: "cli-init" */ '../../src/cli/init.js');
+ const environmentName = 'GRAPHQL_AGENT_AUTHORIZATION';
+ const authorizationPlaceholder = `Bearer \${${environmentName}}`;
+
+ await runInit({
+ endpoint: 'https://example.com/graphql',
+ header: ['Authorization: Bearer test123'],
+ output: 'test-config.json',
+ });
+
+ const writtenConfig = mockWriteFileSync.mock.calls[0]?.[1] as string;
+ expect(writtenConfig).not.toContain('test123');
+ expect(writtenConfig).toContain(authorizationPlaceholder);
});
it('should write config to file when output is specified', async () => {
diff --git a/tests/cli/serve.test.ts b/tests/cli/serve.test.ts
new file mode 100644
index 0000000..ef3c6df
--- /dev/null
+++ b/tests/cli/serve.test.ts
@@ -0,0 +1,51 @@
+import { mkdtempSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { runServe } from '../../src/cli/serve.js';
+
+const { mockConnect, mockCreateAgentToolkitServer } = vi.hoisted(() => {
+ const connect = vi.fn();
+ return {
+ mockConnect: connect,
+ mockCreateAgentToolkitServer: vi.fn().mockResolvedValue({ connect }),
+ };
+});
+
+vi.mock('../../src/mcp/server.js', () => ({
+ createAgentToolkitServer: mockCreateAgentToolkitServer,
+}));
+
+vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
+ StdioServerTransport: vi.fn(),
+}));
+
+describe('runServe', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ delete process.env.GRAPHQL_AGENT_AUTHORIZATION;
+ });
+
+ it('should expand env placeholders from config headers before creating the server', async () => {
+ process.env.GRAPHQL_AGENT_AUTHORIZATION = 'secret-token';
+ const directory = mkdtempSync(path.join(tmpdir(), 'gat-'));
+ const configPath = path.join(directory, 'config.json');
+ const environmentName = 'GRAPHQL_AGENT_AUTHORIZATION';
+ const authorizationPlaceholder = `Bearer \${${environmentName}}`;
+ writeFileSync(
+ configPath,
+ JSON.stringify({
+ endpoint: 'https://example.com/graphql',
+ headers: { Authorization: authorizationPlaceholder },
+ }),
+ );
+
+ await runServe({ config: configPath });
+
+ expect(mockCreateAgentToolkitServer).toHaveBeenCalledWith({
+ endpoint: 'https://example.com/graphql',
+ headers: { Authorization: 'Bearer secret-token' },
+ });
+ expect(mockConnect).toHaveBeenCalledOnce();
+ });
+});
diff --git a/tests/mcp/tool-factory.test.ts b/tests/mcp/tool-factory.test.ts
index 4d9bd56..d8a0d25 100644
--- a/tests/mcp/tool-factory.test.ts
+++ b/tests/mcp/tool-factory.test.ts
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
+import { z } from 'zod';
import { describe, it, expect, vi } from 'vitest';
import { createToolsFromSchema } from '../../src/mcp/tool-factory.js';
import { parseSchema } from '../../src/introspection/parser.js';
@@ -55,6 +56,23 @@ describe('createToolsFromSchema', () => {
expect('id' in userTool.inputSchema).toBe(true);
});
+ it('should preserve GraphQL required and nullable argument semantics in Zod schemas', () => {
+ const tools = createToolsFromSchema(schema, mockExecutor);
+
+ const userTool = tools.find((t) => t.name === 'query_user');
+ assert.ok(userTool);
+ const userSchema = z.object(userTool.inputSchema);
+ expect(userSchema.parse({ id: '123' }).id).toBe('123');
+ expect(() => userSchema.parse({})).toThrow();
+ expect(() => userSchema.parse({ id: null })).toThrow();
+
+ const usersTool = tools.find((t) => t.name === 'query_users');
+ assert.ok(usersTool);
+ const usersSchema = z.object(usersTool.inputSchema);
+ expect(usersSchema.parse({})).toEqual({});
+ expect(usersSchema.parse({ limit: null }).limit).toBeNull();
+ });
+
it('should use field description as tool description', () => {
const tools = createToolsFromSchema(schema, mockExecutor);
diff --git a/tsup.config.ts b/tsup.config.ts
index c1e1d71..3274a84 100644
--- a/tsup.config.ts
+++ b/tsup.config.ts
@@ -12,5 +12,5 @@ export default defineConfig({
shims: true,
sourcemap: true,
splitting: false,
- target: 'node18',
+ target: 'node22',
});