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 @@ CI License: MIT npm - Node 18+ + Node 22+ Ask DeepWiki Socket

@@ -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', });