# Directory Structure ``` ├── .dockerignore ├── .gitattributes ├── .github │ └── workflows │ ├── ci.yml │ └── docker.yml ├── .gitignore ├── .npmrc ├── .vscode │ └── settings.json ├── biome.json ├── bun.lock ├── CONTRIBUTING.md ├── dev │ ├── debug-client.ts │ ├── debug-manual-client.ts │ └── graphql.ts ├── Dockerfile ├── LICENSE ├── package.json ├── README.md ├── smithery.yaml ├── src │ ├── helpers │ │ ├── deprecation.ts │ │ ├── headers.ts │ │ ├── introspection.ts │ │ └── package.ts │ └── index.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- ``` 1 | package-lock=false 2 | ``` -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- ``` 1 | *.ts linguist-language=TypeScript 2 | ``` -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- ``` 1 | node_modules 2 | Dockerfile* 3 | docker-compose* 4 | .dockerignore 5 | .git 6 | .gitignore 7 | README.md 8 | LICENSE 9 | .vscode 10 | Makefile 11 | helm-charts 12 | .env 13 | .editorconfig 14 | .idea 15 | coverage* 16 | dist 17 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | 177 | dist/ 178 | 179 | # GraphQL schema for debugging 180 | /schema.graphql 181 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # mcp-graphql 2 | 3 | [](https://smithery.ai/server/mcp-graphql) 4 | 5 | A Model Context Protocol server that enables LLMs to interact with GraphQL APIs. This implementation provides schema introspection and query execution capabilities, allowing models to discover and use GraphQL APIs dynamically. 6 | 7 | <a href="https://glama.ai/mcp/servers/4zwa4l8utf"><img width="380" height="200" src="https://glama.ai/mcp/servers/4zwa4l8utf/badge" alt="mcp-graphql MCP server" /></a> 8 | 9 | ## Usage 10 | 11 | Run `mcp-graphql` with the correct endpoint, it will automatically try to introspect your queries. 12 | 13 | ### Environment Variables (Breaking change in 1.0.0) 14 | 15 | > **Note:** As of version 1.0.0, command line arguments have been replaced with environment variables. 16 | 17 | | Environment Variable | Description | Default | 18 | |----------|-------------|---------| 19 | | `ENDPOINT` | GraphQL endpoint URL | `http://localhost:4000/graphql` | 20 | | `HEADERS` | JSON string containing headers for requests | `{}` | 21 | | `ALLOW_MUTATIONS` | Enable mutation operations (disabled by default) | `false` | 22 | | `NAME` | Name of the MCP server | `mcp-graphql` | 23 | | `SCHEMA` | Path to a local GraphQL schema file or URL (optional) | - | 24 | 25 | ### Examples 26 | 27 | ```bash 28 | # Basic usage with a local GraphQL server 29 | ENDPOINT=http://localhost:3000/graphql npx mcp-graphql 30 | 31 | # Using with custom headers 32 | ENDPOINT=https://api.example.com/graphql HEADERS='{"Authorization":"Bearer token123"}' npx mcp-graphql 33 | 34 | # Enable mutation operations 35 | ENDPOINT=http://localhost:3000/graphql ALLOW_MUTATIONS=true npx mcp-graphql 36 | 37 | # Using a local schema file instead of introspection 38 | ENDPOINT=http://localhost:3000/graphql SCHEMA=./schema.graphql npx mcp-graphql 39 | 40 | # Using a schema file hosted at a URL 41 | ENDPOINT=http://localhost:3000/graphql SCHEMA=https://example.com/schema.graphql npx mcp-graphql 42 | ``` 43 | 44 | ## Resources 45 | 46 | - **graphql-schema**: The server exposes the GraphQL schema as a resource that clients can access. This is either the local schema file, a schema file hosted at a URL, or based on an introspection query. 47 | 48 | ## Available Tools 49 | 50 | The server provides two main tools: 51 | 52 | 1. **introspect-schema**: This tool retrieves the GraphQL schema. Use this first if you don't have access to the schema as a resource. 53 | This uses either the local schema file, a schema file hosted at a URL, or an introspection query. 54 | 55 | 2. **query-graphql**: Execute GraphQL queries against the endpoint. By default, mutations are disabled unless `ALLOW_MUTATIONS` is set to `true`. 56 | 57 | ## Installation 58 | 59 | ### Installing via Smithery 60 | 61 | To install GraphQL MCP Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/mcp-graphql): 62 | 63 | ```bash 64 | npx -y @smithery/cli install mcp-graphql --client claude 65 | ``` 66 | 67 | ### Installing Manually 68 | 69 | It can be manually installed to Claude: 70 | ```json 71 | { 72 | "mcpServers": { 73 | "mcp-graphql": { 74 | "command": "npx", 75 | "args": ["mcp-graphql"], 76 | "env": { 77 | "ENDPOINT": "http://localhost:3000/graphql" 78 | } 79 | } 80 | } 81 | } 82 | ``` 83 | 84 | ## Security Considerations 85 | 86 | Mutations are disabled by default as a security measure to prevent an LLM from modifying your database or service data. Consider carefully before enabling mutations in production environments. 87 | 88 | ## Customize for your own server 89 | 90 | This is a very generic implementation where it allows for complete introspection and for your users to do whatever (including mutations). If you need a more specific implementation I'd suggest to just create your own MCP and lock down tool calling for clients to only input specific query fields and/or variables. You can use this as a reference. 91 | ``` -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- ```markdown 1 | Yes please! Add issues or pull requests for whatever is missing/should be improved :) 2 | ``` -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "formatter": { 3 | "enabled": true, 4 | "indentStyle": "tab", 5 | "indentWidth": 2 6 | } 7 | } 8 | ``` -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "editor.defaultFormatter": "biomejs.biome", 3 | "[typescript]": { 4 | "editor.defaultFormatter": "biomejs.biome" 5 | } 6 | } 7 | ``` -------------------------------------------------------------------------------- /src/helpers/package.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { readFileSync } from "node:fs"; 2 | import { dirname, join } from "node:path"; 3 | import { fileURLToPath } from "node:url"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | // Current package version so I only need to update it in one place 9 | const { version } = JSON.parse( 10 | readFileSync(join(__dirname, "../../package.json"), "utf-8"), 11 | ); 12 | 13 | export function getVersion() { 14 | return version; 15 | } 16 | ``` -------------------------------------------------------------------------------- /dev/debug-client.ts: -------------------------------------------------------------------------------- ```typescript 1 | // Small debug client to test a few specific interactions 2 | 3 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 4 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; 5 | 6 | const transport = new StdioClientTransport({ 7 | command: "node", 8 | args: ["dist/index.js"], 9 | }); 10 | 11 | const client = new Client({ 12 | name: "debug-client", 13 | version: "1.0.0", 14 | }); 15 | 16 | await client.connect(transport); 17 | 18 | // Call introspect-schema with undefined argument 19 | const result = await client.callTool({ 20 | name: "introspect-schema", 21 | arguments: {}, 22 | }); 23 | 24 | console.log(result); 25 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | required: 9 | - endpoint 10 | properties: 11 | endpoint: 12 | type: string 13 | description: The GraphQL server endpoint URL. 14 | headers: 15 | type: string 16 | description: Optional JSON string of headers to send with the GraphQL requests. 17 | commandFunction: 18 | # A function that produces the CLI command to start the MCP on stdio. 19 | |- 20 | (config) => ({ command: 'node', args: ['/app/dist/index.js', '--endpoint', config.endpoint].concat(config.headers ? ['--headers', config.headers] : []), env: {} }) ``` -------------------------------------------------------------------------------- /src/helpers/headers.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Parse and merge headers from various sources 3 | * @param configHeaders - Default headers from configuration 4 | * @param inputHeaders - Headers provided by the user (string or object) 5 | * @returns Merged headers object 6 | */ 7 | export function parseAndMergeHeaders( 8 | configHeaders: Record<string, string>, 9 | inputHeaders?: string | Record<string, string>, 10 | ): Record<string, string> { 11 | // Parse headers if they're provided as a string 12 | let parsedHeaders: Record<string, string> = {}; 13 | 14 | if (typeof inputHeaders === "string") { 15 | try { 16 | parsedHeaders = JSON.parse(inputHeaders); 17 | } catch (e) { 18 | throw new Error(`Invalid headers JSON: ${e}`); 19 | } 20 | } else if (inputHeaders) { 21 | parsedHeaders = inputHeaders; 22 | } 23 | 24 | // Merge with config headers (config headers are overridden by input headers) 25 | return { ...configHeaders, ...parsedHeaders }; 26 | } 27 | ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | # Bun based Dockerfile 2 | # Does not build the server, but runs it directly from source using bun 3 | 4 | FROM oven/bun:1 AS base 5 | WORKDIR /usr/src/app 6 | 7 | # Cached dependency install layer 8 | FROM base AS install 9 | RUN mkdir -p /temp/dev 10 | COPY package.json bun.lock /temp/dev/ 11 | RUN cd /temp/dev && bun install --frozen-lockfile 12 | 13 | # exclude devDependencies 14 | RUN mkdir -p /temp/prod 15 | COPY package.json bun.lock /temp/prod/ 16 | RUN cd /temp/prod && bun install --frozen-lockfile --production 17 | 18 | FROM base AS prerelease 19 | COPY --from=install /temp/dev/node_modules node_modules 20 | COPY . . 21 | 22 | # copy production dependencies and source code into final image 23 | FROM base AS release 24 | COPY --from=install /temp/prod/node_modules node_modules 25 | COPY --from=prerelease /usr/src/app/src/ ./src/ 26 | COPY --from=prerelease /usr/src/app/package.json . 27 | 28 | # run the app 29 | USER bun 30 | ENTRYPOINT [ "bun", "run", "src/index.ts" ] 31 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "mcp-graphql", 3 | "module": "index.ts", 4 | "type": "module", 5 | "version": "2.0.4", 6 | "repository": "github:blurrah/mcp-graphql", 7 | "license": "MIT", 8 | "bin": { 9 | "mcp-graphql": "./dist/index.js" 10 | }, 11 | "files": [ 12 | "dist" 13 | ], 14 | "devDependencies": { 15 | "@graphql-tools/schema": "^10.0.23", 16 | "@types/bun": "^1.2.14", 17 | "@types/yargs": "17.0.33", 18 | "biome": "^0.3.3", 19 | "graphql-yoga": "^5.13.5", 20 | "typescript": "5.8.3" 21 | }, 22 | "dependencies": { 23 | "@modelcontextprotocol/sdk": "1.12.0", 24 | "graphql": "^16.11.0", 25 | "yargs": "17.7.2", 26 | "zod": "3.25.30", 27 | "zod-to-json-schema": "3.24.5" 28 | }, 29 | "scripts": { 30 | "dev": "bun --watch src/index.ts", 31 | "build": "bun build src/index.ts --outdir dist --target node && bun -e \"require('fs').chmodSync('dist/index.js', '755')\"", 32 | "start": "bun run dist/index.js", 33 | "format": "biome format --write .", 34 | "check": "biome format ." 35 | }, 36 | "packageManager": "[email protected]" 37 | } 38 | ``` -------------------------------------------------------------------------------- /src/helpers/deprecation.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Helper module for handling deprecation warnings 3 | */ 4 | 5 | /** 6 | * Check for deprecated command line arguments and output warnings 7 | */ 8 | export function checkDeprecatedArguments(): void { 9 | const deprecatedArgs = [ 10 | "--endpoint", 11 | "--headers", 12 | "--enable-mutations", 13 | "--name", 14 | "--schema", 15 | ]; 16 | const usedDeprecatedArgs = deprecatedArgs.filter((arg) => 17 | process.argv.includes(arg), 18 | ); 19 | 20 | if (usedDeprecatedArgs.length > 0) { 21 | console.error( 22 | `WARNING: Deprecated command line arguments detected: ${usedDeprecatedArgs.join(", ")}`, 23 | ); 24 | console.error( 25 | "As of version 1.0.0, command line arguments have been replaced with environment variables.", 26 | ); 27 | console.error("Please use environment variables instead. For example:"); 28 | console.error( 29 | " Instead of: npx mcp-graphql --endpoint http://example.com/graphql", 30 | ); 31 | console.error(" Use: ENDPOINT=http://example.com/graphql npx mcp-graphql"); 32 | console.error(""); 33 | } 34 | } 35 | ``` -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- ```yaml 1 | name: docker 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | 7 | env: 8 | REGISTRY: ghcr.io 9 | IMAGE_NAME: ${{ github.repository }} 10 | 11 | jobs: 12 | build-and-push-image: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | packages: write 17 | attestations: write 18 | id-token: write 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Log in to the Container registry 25 | uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 26 | with: 27 | registry: ${{ env.REGISTRY }} 28 | username: ${{ github.actor }} 29 | password: ${{ secrets.GITHUB_TOKEN }} 30 | 31 | - name: Extract metadata (tags, labels) for Docker 32 | id: meta 33 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 34 | with: 35 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 36 | 37 | - name: Build and push Docker image 38 | id: push 39 | uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 40 | with: 41 | context: . 42 | push: true 43 | tags: ${{ steps.meta.outputs.tags }} 44 | labels: ${{ steps.meta.outputs.labels }} 45 | 46 | 47 | - name: Generate artifact attestation 48 | uses: actions/attest-build-provenance@v2 49 | with: 50 | subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} 51 | subject-digest: ${{ steps.push.outputs.digest }} 52 | push-to-registry: true 53 | 54 | ``` -------------------------------------------------------------------------------- /src/helpers/introspection.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { buildClientSchema, getIntrospectionQuery, printSchema } from "graphql"; 2 | import { readFile } from "node:fs/promises"; 3 | 4 | /** 5 | * Introspect a GraphQL endpoint and return the schema as the GraphQL SDL 6 | * @param endpoint - The endpoint to introspect 7 | * @param headers - Optional headers to include in the request 8 | * @returns The schema 9 | */ 10 | export async function introspectEndpoint( 11 | endpoint: string, 12 | headers?: Record<string, string>, 13 | ) { 14 | const response = await fetch(endpoint, { 15 | method: "POST", 16 | headers: { 17 | "Content-Type": "application/json", 18 | ...headers, 19 | }, 20 | body: JSON.stringify({ 21 | query: getIntrospectionQuery(), 22 | }), 23 | }); 24 | 25 | if (!response.ok) { 26 | throw new Error(`GraphQL request failed: ${response.statusText}`); 27 | } 28 | 29 | const responseJson = await response.json(); 30 | // Transform to a schema object 31 | const schema = buildClientSchema(responseJson.data); 32 | 33 | // Print the schema SDL 34 | return printSchema(schema); 35 | } 36 | 37 | /** 38 | * Introspect a GraphQL schema file hosted at a URL and return the schema as the GraphQL SDL 39 | * @param url - The URL to the schema file 40 | * @returns The schema 41 | */ 42 | export async function introspectSchemaFromUrl(url: string) { 43 | const response = await fetch(url); 44 | 45 | if (!response.ok) { 46 | throw new Error(`Failed to fetch schema from URL: ${response.statusText}`); 47 | } 48 | 49 | const schema = await response.text(); 50 | return schema; 51 | } 52 | 53 | /** 54 | * Introspect a local GraphQL schema file and return the schema as the GraphQL SDL 55 | * @param path - The path to the local schema file 56 | * @returns The schema 57 | */ 58 | export async function introspectLocalSchema(path: string) { 59 | const schema = await readFile(path, "utf8"); 60 | return schema; 61 | } 62 | ``` -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- ```yaml 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - v* 9 | pull_request: 10 | types: [opened, synchronize] 11 | release: 12 | types: [published] 13 | workflow_dispatch: 14 | inputs: 15 | version: 16 | description: 'Version to release' 17 | required: true 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | if: github.event_name != 'release' 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | - uses: oven-sh/setup-bun@v2 28 | with: 29 | bun-version: latest 30 | - run: bun install 31 | - run: bun run format 32 | - run: bun run check 33 | - run: bun run build 34 | - name: Get tag annotation 35 | id: get-tag-annotation 36 | run: | 37 | TAG_NAME=${GITHUB_REF#refs/tags/} 38 | echo "Processing tag: $TAG_NAME" 39 | # Check if tag exists and is annotated 40 | if TAG_MESSAGE=$(git tag -l --format='%(contents)' $TAG_NAME 2>/dev/null); then 41 | echo "Found annotated tag message" 42 | else 43 | echo "No tag annotation found, using empty message" 44 | TAG_MESSAGE="" 45 | fi 46 | # Use multiline output syntax for GitHub Actions 47 | echo "TAG_MESSAGE<<EOF" >> $GITHUB_OUTPUT 48 | echo "$TAG_MESSAGE" >> $GITHUB_OUTPUT 49 | echo "EOF" >> $GITHUB_OUTPUT 50 | 51 | - name: Create GitHub Release 52 | uses: softprops/action-gh-release@v2 53 | if: startsWith(github.ref, 'refs/tags/') 54 | with: 55 | files: dist/* 56 | generate_release_notes: true 57 | body: ${{ steps.get-tag-annotation.outputs.TAG_MESSAGE }} 58 | append_body: true 59 | # Use PAT as it sends a release event, built in token doesn't 60 | token: ${{ secrets.RELEASE_TOKEN }} 61 | 62 | publish: 63 | runs-on: ubuntu-latest 64 | if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' && inputs.version != '' 65 | permissions: 66 | contents: read 67 | id-token: write 68 | steps: 69 | - uses: actions/checkout@v4 70 | # Falling back to node since I want provenance for the npm package 71 | - uses: actions/setup-node@v4 72 | with: 73 | node-version: 22 74 | registry-url: 'https://registry.npmjs.org' 75 | # Might be a useless optimization but I felt like not reusing the build job 76 | - name: Download index.js from release 77 | run: | 78 | gh release download ${{ github.event.release.tag_name || inputs.version }} --dir dist --pattern "index.js" 79 | env: 80 | GH_TOKEN: ${{ github.token }} 81 | - run: npm publish --provenance --access public 82 | env: 83 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 84 | ``` -------------------------------------------------------------------------------- /dev/debug-manual-client.ts: -------------------------------------------------------------------------------- ```typescript 1 | // Manual MCP client using stdio directly (no SDK) 2 | // This demonstrates the raw JSON-RPC protocol communication 3 | 4 | import { type ChildProcess, spawn } from "node:child_process"; 5 | import { createInterface } from "node:readline"; 6 | 7 | interface JsonRpcMessage { 8 | jsonrpc: "2.0"; 9 | id?: string | number; 10 | method?: string; 11 | params?: unknown; 12 | result?: unknown; 13 | error?: { 14 | code: number; 15 | message: string; 16 | data?: unknown; 17 | }; 18 | } 19 | 20 | class ManualMcpClient { 21 | private serverProcess: ChildProcess; 22 | private messageId = 1; 23 | private pendingRequests = new Map< 24 | string | number, 25 | (response: JsonRpcMessage) => void 26 | >(); 27 | 28 | constructor() { 29 | // Start the MCP server process 30 | this.serverProcess = spawn("node", ["dist/index.js"], { 31 | stdio: ["pipe", "pipe", "pipe"], 32 | }); 33 | 34 | // Set up readline to read server responses line by line 35 | if (this.serverProcess.stdout) { 36 | const rl = createInterface({ 37 | input: this.serverProcess.stdout, 38 | }); 39 | 40 | rl.on("line", (line) => { 41 | try { 42 | const message: JsonRpcMessage = JSON.parse(line); 43 | this.handleServerMessage(message); 44 | } catch (error) { 45 | console.error("Failed to parse server message:", line, error); 46 | } 47 | }); 48 | } 49 | 50 | // Handle server errors 51 | this.serverProcess.stderr?.on("data", (data: Buffer) => { 52 | console.error("Server stderr:", data.toString()); 53 | }); 54 | 55 | this.serverProcess.on("exit", (code: number | null) => { 56 | console.log(`Server process exited with code ${code}`); 57 | }); 58 | } 59 | 60 | private handleServerMessage(message: JsonRpcMessage) { 61 | console.log("← Received from server:", JSON.stringify(message, null, 2)); 62 | 63 | // Handle responses to our requests 64 | if (message.id !== undefined && this.pendingRequests.has(message.id)) { 65 | const resolver = this.pendingRequests.get(message.id); 66 | if (resolver) { 67 | this.pendingRequests.delete(message.id); 68 | resolver(message); 69 | } 70 | } 71 | } 72 | 73 | private sendMessage(message: JsonRpcMessage): Promise<JsonRpcMessage> { 74 | const messageStr = JSON.stringify(message); 75 | console.log("→ Sending to server:", messageStr); 76 | 77 | this.serverProcess.stdin?.write(`${messageStr}\n`); 78 | 79 | // If this is a request (has an id), wait for response 80 | if (message.id !== undefined) { 81 | return new Promise((resolve) => { 82 | if (message.id !== undefined) { 83 | this.pendingRequests.set(message.id, resolve); 84 | } 85 | }); 86 | } 87 | 88 | return Promise.resolve(message); 89 | } 90 | 91 | private getNextId(): number { 92 | return this.messageId++; 93 | } 94 | 95 | async initialize(): Promise<JsonRpcMessage> { 96 | const initMessage: JsonRpcMessage = { 97 | jsonrpc: "2.0", 98 | method: "initialize", 99 | params: { 100 | protocolVersion: "2025-03-26", 101 | capabilities: {}, 102 | clientInfo: { 103 | name: "manual-debug-client", 104 | version: "1.0.0", 105 | }, 106 | }, 107 | id: this.getNextId(), 108 | }; 109 | 110 | const response = await this.sendMessage(initMessage); 111 | 112 | // Send initialized notification 113 | const initializedNotification: JsonRpcMessage = { 114 | jsonrpc: "2.0", 115 | method: "notifications/initialized", 116 | }; 117 | 118 | await this.sendMessage(initializedNotification); 119 | 120 | return response; 121 | } 122 | 123 | async ping(): Promise<JsonRpcMessage> { 124 | const pingMessage: JsonRpcMessage = { 125 | jsonrpc: "2.0", 126 | method: "ping", 127 | id: this.getNextId(), 128 | }; 129 | 130 | return this.sendMessage(pingMessage); 131 | } 132 | 133 | async introspectSchema(): Promise<JsonRpcMessage> { 134 | const introspectMessage: JsonRpcMessage = { 135 | jsonrpc: "2.0", 136 | method: "tools/call", 137 | params: { 138 | name: "introspect-schema", 139 | arguments: {}, 140 | }, 141 | id: this.getNextId(), 142 | }; 143 | 144 | return this.sendMessage(introspectMessage); 145 | } 146 | 147 | async listTools(): Promise<JsonRpcMessage> { 148 | const listToolsMessage: JsonRpcMessage = { 149 | jsonrpc: "2.0", 150 | method: "tools/list", 151 | params: {}, 152 | id: this.getNextId(), 153 | }; 154 | 155 | return this.sendMessage(listToolsMessage); 156 | } 157 | 158 | async close() { 159 | this.serverProcess.kill(); 160 | } 161 | } 162 | 163 | // Main execution 164 | async function main() { 165 | console.log("🚀 Starting manual MCP client..."); 166 | 167 | const client = new ManualMcpClient(); 168 | 169 | try { 170 | // Wait a bit for the server to start 171 | await new Promise((resolve) => setTimeout(resolve, 1000)); 172 | 173 | console.log("\n📋 Step 1: Initialize connection"); 174 | const initResponse = await client.initialize(); 175 | console.log("✅ Initialization complete"); 176 | 177 | console.log("\n📋 Step 2: Ping server"); 178 | const pingResponse = await client.ping(); 179 | console.log("✅ Ping successful"); 180 | 181 | console.log("\n📋 Step 3: List available tools"); 182 | const toolsResponse = await client.listTools(); 183 | console.log("✅ Tools listed"); 184 | 185 | console.log("\n📋 Step 4: Call introspect-schema tool"); 186 | const schemaResponse = await client.introspectSchema(); 187 | console.log("✅ Schema introspection complete"); 188 | 189 | console.log("\n🎉 All operations completed successfully!"); 190 | } catch (error) { 191 | console.error("❌ Error:", error); 192 | } finally { 193 | console.log("\n🔚 Closing client..."); 194 | client.close(); 195 | } 196 | } 197 | 198 | main().catch(console.error); 199 | ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 5 | import { parse } from "graphql/language"; 6 | import { z } from "zod"; 7 | import { checkDeprecatedArguments } from "./helpers/deprecation.js"; 8 | import { 9 | introspectEndpoint, 10 | introspectLocalSchema, 11 | introspectSchemaFromUrl, 12 | } from "./helpers/introspection.js"; 13 | import { getVersion } from "./helpers/package.js" with { type: "macro" }; 14 | 15 | // Check for deprecated command line arguments 16 | checkDeprecatedArguments(); 17 | 18 | const EnvSchema = z.object({ 19 | NAME: z.string().default("mcp-graphql"), 20 | ENDPOINT: z.string().url().default("http://localhost:4000/graphql"), 21 | ALLOW_MUTATIONS: z 22 | .enum(["true", "false"]) 23 | .transform((value) => value === "true") 24 | .default("false"), 25 | HEADERS: z 26 | .string() 27 | .default("{}") 28 | .transform((val) => { 29 | try { 30 | return JSON.parse(val); 31 | } catch (e) { 32 | throw new Error("HEADERS must be a valid JSON string"); 33 | } 34 | }), 35 | SCHEMA: z.string().optional(), 36 | }); 37 | 38 | const env = EnvSchema.parse(process.env); 39 | 40 | const server = new McpServer({ 41 | name: env.NAME, 42 | version: getVersion(), 43 | description: `GraphQL MCP server for ${env.ENDPOINT}`, 44 | }); 45 | 46 | server.resource("graphql-schema", new URL(env.ENDPOINT).href, async (uri) => { 47 | try { 48 | let schema: string; 49 | if (env.SCHEMA) { 50 | if ( 51 | env.SCHEMA.startsWith("http://") || 52 | env.SCHEMA.startsWith("https://") 53 | ) { 54 | schema = await introspectSchemaFromUrl(env.SCHEMA); 55 | } else { 56 | schema = await introspectLocalSchema(env.SCHEMA); 57 | } 58 | } else { 59 | schema = await introspectEndpoint(env.ENDPOINT, env.HEADERS); 60 | } 61 | 62 | return { 63 | contents: [ 64 | { 65 | uri: uri.href, 66 | text: schema, 67 | }, 68 | ], 69 | }; 70 | } catch (error) { 71 | throw new Error(`Failed to get GraphQL schema: ${error}`); 72 | } 73 | }); 74 | 75 | server.tool( 76 | "introspect-schema", 77 | "Introspect the GraphQL schema, use this tool before doing a query to get the schema information if you do not have it available as a resource already.", 78 | { 79 | // This is a workaround to help clients that can't handle an empty object as an argument 80 | // They will often send undefined instead of an empty object which is not allowed by the schema 81 | __ignore__: z 82 | .boolean() 83 | .default(false) 84 | .describe("This does not do anything"), 85 | }, 86 | async () => { 87 | try { 88 | let schema: string; 89 | if (env.SCHEMA) { 90 | schema = await introspectLocalSchema(env.SCHEMA); 91 | } else { 92 | schema = await introspectEndpoint(env.ENDPOINT, env.HEADERS); 93 | } 94 | 95 | return { 96 | content: [ 97 | { 98 | type: "text", 99 | text: schema, 100 | }, 101 | ], 102 | }; 103 | } catch (error) { 104 | return { 105 | isError: true, 106 | content: [ 107 | { 108 | type: "text", 109 | text: `Failed to introspect schema: ${error}`, 110 | }, 111 | ], 112 | }; 113 | } 114 | }, 115 | ); 116 | 117 | server.tool( 118 | "query-graphql", 119 | "Query a GraphQL endpoint with the given query and variables", 120 | { 121 | query: z.string(), 122 | variables: z.string().optional(), 123 | }, 124 | async ({ query, variables }) => { 125 | try { 126 | const parsedQuery = parse(query); 127 | 128 | // Check if the query is a mutation 129 | const isMutation = parsedQuery.definitions.some( 130 | (def) => 131 | def.kind === "OperationDefinition" && def.operation === "mutation", 132 | ); 133 | 134 | if (isMutation && !env.ALLOW_MUTATIONS) { 135 | return { 136 | isError: true, 137 | content: [ 138 | { 139 | type: "text", 140 | text: "Mutations are not allowed unless you enable them in the configuration. Please use a query operation instead.", 141 | }, 142 | ], 143 | }; 144 | } 145 | } catch (error) { 146 | return { 147 | isError: true, 148 | content: [ 149 | { 150 | type: "text", 151 | text: `Invalid GraphQL query: ${error}`, 152 | }, 153 | ], 154 | }; 155 | } 156 | 157 | try { 158 | const response = await fetch(env.ENDPOINT, { 159 | method: "POST", 160 | headers: { 161 | "Content-Type": "application/json", 162 | ...env.HEADERS, 163 | }, 164 | body: JSON.stringify({ 165 | query, 166 | variables, 167 | }), 168 | }); 169 | 170 | if (!response.ok) { 171 | const responseText = await response.text(); 172 | 173 | return { 174 | isError: true, 175 | content: [ 176 | { 177 | type: "text", 178 | text: `GraphQL request failed: ${response.statusText}\n${responseText}`, 179 | }, 180 | ], 181 | }; 182 | } 183 | 184 | const data = await response.json(); 185 | 186 | if (data.errors && data.errors.length > 0) { 187 | // Contains GraphQL errors 188 | return { 189 | isError: true, 190 | content: [ 191 | { 192 | type: "text", 193 | text: `The GraphQL response has errors, please fix the query: ${JSON.stringify( 194 | data, 195 | null, 196 | 2, 197 | )}`, 198 | }, 199 | ], 200 | }; 201 | } 202 | 203 | return { 204 | content: [ 205 | { 206 | type: "text", 207 | text: JSON.stringify(data, null, 2), 208 | }, 209 | ], 210 | }; 211 | } catch (error) { 212 | throw new Error(`Failed to execute GraphQL query: ${error}`); 213 | } 214 | }, 215 | ); 216 | 217 | async function main() { 218 | const transport = new StdioServerTransport(); 219 | await server.connect(transport); 220 | 221 | console.error( 222 | `Started graphql mcp server ${env.NAME} for endpoint: ${env.ENDPOINT}`, 223 | ); 224 | } 225 | 226 | main().catch((error) => { 227 | console.error(`Fatal error in main(): ${error}`); 228 | process.exit(1); 229 | }); 230 | ``` -------------------------------------------------------------------------------- /dev/graphql.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { makeExecutableSchema } from "@graphql-tools/schema"; 2 | import { createYoga } from "graphql-yoga"; 3 | import fs from "node:fs"; 4 | 5 | /** 6 | * Simple GraphQL server implementation for testing purposes 7 | * 8 | * This is a simple GraphQL server implementation for testing purposes. 9 | * It is not intended to be used in production. 10 | * 11 | * It is used to test the GraphQL schema and resolvers. 12 | * 13 | */ 14 | 15 | // Define types 16 | interface User { 17 | id: string; 18 | name: string; 19 | email: string; 20 | createdAt: string; 21 | updatedAt: string | null; 22 | } 23 | 24 | interface Post { 25 | id: string; 26 | title: string; 27 | content: string; 28 | published: boolean; 29 | authorId: string; 30 | createdAt: string; 31 | updatedAt: string | null; 32 | } 33 | 34 | interface Comment { 35 | id: string; 36 | text: string; 37 | postId: string; 38 | authorId: string; 39 | createdAt: string; 40 | } 41 | 42 | interface CreateUserInput { 43 | name: string; 44 | email: string; 45 | } 46 | 47 | interface UpdateUserInput { 48 | name?: string; 49 | email?: string; 50 | } 51 | 52 | interface CreatePostInput { 53 | title: string; 54 | content: string; 55 | published?: boolean; 56 | authorId: string; 57 | } 58 | 59 | interface AddCommentInput { 60 | text: string; 61 | postId: string; 62 | authorId: string; 63 | } 64 | 65 | // Define resolver context type 66 | type ResolverContext = Record<string, never>; 67 | 68 | // Read schema from file 69 | const typeDefs = fs.readFileSync("./schema-simple.graphql", "utf-8"); 70 | 71 | // Create mock data 72 | const users: User[] = [ 73 | { 74 | id: "1", 75 | name: "John Doe", 76 | email: "[email protected]", 77 | createdAt: new Date().toISOString(), 78 | updatedAt: null, 79 | }, 80 | { 81 | id: "2", 82 | name: "Jane Smith", 83 | email: "[email protected]", 84 | createdAt: new Date().toISOString(), 85 | updatedAt: null, 86 | }, 87 | { 88 | id: "3", 89 | name: "Bob Johnson", 90 | email: "[email protected]", 91 | createdAt: new Date().toISOString(), 92 | updatedAt: null, 93 | }, 94 | ]; 95 | 96 | const posts: Post[] = [ 97 | { 98 | id: "1", 99 | title: "First Post", 100 | content: "This is my first post", 101 | published: true, 102 | authorId: "1", 103 | createdAt: new Date().toISOString(), 104 | updatedAt: null, 105 | }, 106 | { 107 | id: "2", 108 | title: "GraphQL is Awesome", 109 | content: "Here is why GraphQL is better than REST", 110 | published: true, 111 | authorId: "1", 112 | createdAt: new Date().toISOString(), 113 | updatedAt: null, 114 | }, 115 | { 116 | id: "3", 117 | title: "Yoga Tutorial", 118 | content: "Learn how to use GraphQL Yoga", 119 | published: false, 120 | authorId: "2", 121 | createdAt: new Date().toISOString(), 122 | updatedAt: null, 123 | }, 124 | ]; 125 | 126 | const comments: Comment[] = [ 127 | { 128 | id: "1", 129 | text: "Great post!", 130 | postId: "1", 131 | authorId: "2", 132 | createdAt: new Date().toISOString(), 133 | }, 134 | { 135 | id: "2", 136 | text: "I learned a lot", 137 | postId: "1", 138 | authorId: "3", 139 | createdAt: new Date().toISOString(), 140 | }, 141 | { 142 | id: "3", 143 | text: "Looking forward to more content", 144 | postId: "2", 145 | authorId: "2", 146 | createdAt: new Date().toISOString(), 147 | }, 148 | ]; 149 | 150 | // Define resolvers 151 | const resolvers = { 152 | Query: { 153 | user: ( 154 | _parent: unknown, 155 | { id }: { id: string }, 156 | _context: ResolverContext, 157 | ) => users.find((user) => user.id === id), 158 | users: () => users, 159 | post: ( 160 | _parent: unknown, 161 | { id }: { id: string }, 162 | _context: ResolverContext, 163 | ) => posts.find((post) => post.id === id), 164 | posts: () => posts, 165 | commentsByPost: ( 166 | _parent: unknown, 167 | { postId }: { postId: string }, 168 | _context: ResolverContext, 169 | ) => comments.filter((comment) => comment.postId === postId), 170 | }, 171 | Mutation: { 172 | createUser: ( 173 | _parent: unknown, 174 | { input }: { input: CreateUserInput }, 175 | _context: ResolverContext, 176 | ) => { 177 | const newUser: User = { 178 | id: String(users.length + 1), 179 | name: input.name, 180 | email: input.email, 181 | createdAt: new Date().toISOString(), 182 | updatedAt: null, 183 | }; 184 | users.push(newUser); 185 | return newUser; 186 | }, 187 | updateUser: ( 188 | _parent: unknown, 189 | { id, input }: { id: string; input: UpdateUserInput }, 190 | _context: ResolverContext, 191 | ) => { 192 | const userIndex = users.findIndex((user) => user.id === id); 193 | if (userIndex === -1) throw new Error(`User with ID ${id} not found`); 194 | 195 | users[userIndex] = { 196 | ...users[userIndex], 197 | ...input, 198 | updatedAt: new Date().toISOString(), 199 | }; 200 | 201 | return users[userIndex]; 202 | }, 203 | deleteUser: ( 204 | _parent: unknown, 205 | { id }: { id: string }, 206 | _context: ResolverContext, 207 | ) => { 208 | const userIndex = users.findIndex((user) => user.id === id); 209 | if (userIndex === -1) return false; 210 | 211 | users.splice(userIndex, 1); 212 | return true; 213 | }, 214 | createPost: ( 215 | _parent: unknown, 216 | { input }: { input: CreatePostInput }, 217 | _context: ResolverContext, 218 | ) => { 219 | const newPost: Post = { 220 | id: String(posts.length + 1), 221 | title: input.title, 222 | content: input.content, 223 | published: input.published ?? false, 224 | authorId: input.authorId, 225 | createdAt: new Date().toISOString(), 226 | updatedAt: null, 227 | }; 228 | posts.push(newPost); 229 | return newPost; 230 | }, 231 | addComment: ( 232 | _parent: unknown, 233 | { input }: { input: AddCommentInput }, 234 | _context: ResolverContext, 235 | ) => { 236 | const newComment: Comment = { 237 | id: String(comments.length + 1), 238 | text: input.text, 239 | postId: input.postId, 240 | authorId: input.authorId, 241 | createdAt: new Date().toISOString(), 242 | }; 243 | comments.push(newComment); 244 | return newComment; 245 | }, 246 | }, 247 | User: { 248 | posts: (parent: User) => 249 | posts.filter((post) => post.authorId === parent.id), 250 | comments: (parent: User) => 251 | comments.filter((comment) => comment.authorId === parent.id), 252 | }, 253 | Post: { 254 | author: (parent: Post) => users.find((user) => user.id === parent.authorId), 255 | comments: (parent: Post) => 256 | comments.filter((comment) => comment.postId === parent.id), 257 | }, 258 | Comment: { 259 | post: (parent: Comment) => posts.find((post) => post.id === parent.postId), 260 | author: (parent: Comment) => 261 | users.find((user) => user.id === parent.authorId), 262 | }, 263 | }; 264 | 265 | // Create executable schema 266 | const schema = makeExecutableSchema({ 267 | typeDefs, 268 | resolvers, 269 | }); 270 | 271 | // Create Yoga instance 272 | const yoga = createYoga({ schema }); 273 | 274 | // Start server with proper request handler 275 | const server = Bun.serve({ 276 | port: 4000, 277 | fetch: (request) => { 278 | // Add dev logger for incoming requests 279 | console.log( 280 | `[${new Date().toISOString()}] Incoming request: ${request.method} ${ 281 | request.url 282 | }`, 283 | ); 284 | return yoga.fetch(request); 285 | }, 286 | }); 287 | 288 | console.info( 289 | `GraphQL server is running on ${new URL( 290 | yoga.graphqlEndpoint, 291 | `http://${server.hostname}:${server.port}`, 292 | )}`, 293 | ); 294 | ```