# 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 |
```