diff --git a/README.md b/README.md index 264c0f0..abe8972 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Run `mcp-graphql` with the correct endpoint, it will automatically try to intros | `HEADERS` | JSON string containing headers for requests | `{}` | | `ALLOW_MUTATIONS` | Enable mutation operations (disabled by default) | `false` | | `NAME` | Name of the MCP server | `mcp-graphql` | -| `SCHEMA` | Path to a local GraphQL schema file (optional) | - | +| `SCHEMA` | Path to a local GraphQL schema file or URL (optional) | - | ### Examples @@ -36,18 +36,21 @@ ENDPOINT=http://localhost:3000/graphql ALLOW_MUTATIONS=true npx mcp-graphql # Using a local schema file instead of introspection ENDPOINT=http://localhost:3000/graphql SCHEMA=./schema.graphql npx mcp-graphql + +# Using a schema file hosted at a URL +ENDPOINT=http://localhost:3000/graphql SCHEMA=https://example.com/schema.graphql npx mcp-graphql ``` ## Resources -- **graphql-schema**: The server exposes the GraphQL schema as a resource that clients can access. This is either the local schema file or based on an introspection query. +- **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. ## Available Tools The server provides two main tools: 1. **introspect-schema**: This tool retrieves the GraphQL schema. Use this first if you don't have access to the schema as a resource. -This uses either the local schema file or an introspection query. +This uses either the local schema file, a schema file hosted at a URL, or an introspection query. 2. **query-graphql**: Execute GraphQL queries against the endpoint. By default, mutations are disabled unless `ALLOW_MUTATIONS` is set to `true`. diff --git a/src/helpers/introspection.ts b/src/helpers/introspection.ts index 7394934..1e1bd53 100644 --- a/src/helpers/introspection.ts +++ b/src/helpers/introspection.ts @@ -1,35 +1,53 @@ -import { buildClientSchema, getIntrospectionQuery, printSchema } from "graphql"; -import { readFile } from "node:fs/promises"; +import {buildClientSchema, getIntrospectionQuery, printSchema} from "graphql"; +import {readFile} from "node:fs/promises"; + /** * Introspect a GraphQL endpoint and return the schema as the GraphQL SDL * @param endpoint - The endpoint to introspect + * @param headers - Optional headers to include in the request * @returns The schema */ export async function introspectEndpoint( - endpoint: string, - headers?: Record, + endpoint: string, + headers?: Record, ) { - const response = await fetch(endpoint, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...headers, - }, - body: JSON.stringify({ - query: getIntrospectionQuery(), - }), - }); - - if (!response.ok) { - throw new Error(`GraphQL request failed: ${response.statusText}`); - } - - const responseJson = await response.json(); - // Transform to a schema object - const schema = buildClientSchema(responseJson.data); - - // Print the schema SDL - return printSchema(schema); + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify({ + query: getIntrospectionQuery(), + }), + }); + + if (!response.ok) { + throw new Error(`GraphQL request failed: ${response.statusText}`); + } + + const responseJson = await response.json(); + // Transform to a schema object + const schema = buildClientSchema(responseJson.data); + + // Print the schema SDL + return printSchema(schema); +} + +/** + * Introspect a GraphQL schema file hosted at a URL and return the schema as the GraphQL SDL + * @param url - The URL to the schema file + * @returns The schema + */ +export async function introspectSchemaFromUrl(url: string) { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch schema from URL: ${response.statusText}`); + } + + const schema = await response.text(); + return schema; } /** @@ -38,6 +56,6 @@ export async function introspectEndpoint( * @returns The schema */ export async function introspectLocalSchema(path: string) { - const schema = await readFile(path, "utf8"); - return schema; + const schema = await readFile(path, "utf8"); + return schema; } diff --git a/src/index.ts b/src/index.ts index d48a121..1467b9a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,221 +1,222 @@ #!/usr/bin/env node -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { parse } from "graphql/language"; -import { z } from "zod"; -import { checkDeprecatedArguments } from "./helpers/deprecation.js"; -import { - introspectEndpoint, - introspectLocalSchema, -} from "./helpers/introspection.js"; -import { getVersion } from "./helpers/package.js" with { type: "macro" }; +import {McpServer} from "@modelcontextprotocol/sdk/server/mcp.js"; +import {StdioServerTransport} from "@modelcontextprotocol/sdk/server/stdio.js"; +import {parse} from "graphql/language"; +import {z} from "zod"; +import {checkDeprecatedArguments} from "./helpers/deprecation.js"; +import {introspectEndpoint, introspectLocalSchema, introspectSchemaFromUrl,} from "./helpers/introspection.js"; +import {getVersion} from "./helpers/package.js" with {type: "macro"}; // Check for deprecated command line arguments checkDeprecatedArguments(); const EnvSchema = z.object({ - NAME: z.string().default("mcp-graphql"), - ENDPOINT: z.string().url().default("http://localhost:4000/graphql"), - ALLOW_MUTATIONS: z - .enum(["true", "false"]) - .transform((value) => value === "true") - .default("false"), - HEADERS: z - .string() - .default("{}") - .transform((val) => { - try { - return JSON.parse(val); - } catch (e) { - throw new Error("HEADERS must be a valid JSON string"); - } - }), - SCHEMA: z.string().optional(), + NAME: z.string().default("mcp-graphql"), + ENDPOINT: z.string().url().default("http://localhost:4000/graphql"), + ALLOW_MUTATIONS: z + .enum(["true", "false"]) + .transform((value) => value === "true") + .default("false"), + HEADERS: z + .string() + .default("{}") + .transform((val) => { + try { + return JSON.parse(val); + } catch (e) { + throw new Error("HEADERS must be a valid JSON string"); + } + }), + SCHEMA: z.string().optional(), }); const env = EnvSchema.parse(process.env); const server = new McpServer({ - name: env.NAME, - version: getVersion(), - description: `GraphQL MCP server for ${env.ENDPOINT}`, + name: env.NAME, + version: getVersion(), + description: `GraphQL MCP server for ${env.ENDPOINT}`, }); server.resource("graphql-schema", new URL(env.ENDPOINT).href, async (uri) => { - try { - let schema: string; - if (env.SCHEMA) { - schema = await introspectLocalSchema(env.SCHEMA); - } else { - schema = await introspectEndpoint(env.ENDPOINT, env.HEADERS); - } - - return { - contents: [ - { - uri: uri.href, - text: schema, - }, - ], - }; - } catch (error) { - throw new Error(`Failed to get GraphQL schema: ${error}`); - } + try { + let schema: string; + if (env.SCHEMA) { + if (env.SCHEMA.startsWith("http://") || env.SCHEMA.startsWith("https://")) { + schema = await introspectSchemaFromUrl(env.SCHEMA); + } else { + schema = await introspectLocalSchema(env.SCHEMA); + } + } else { + schema = await introspectEndpoint(env.ENDPOINT, env.HEADERS); + } + + return { + contents: [ + { + uri: uri.href, + text: schema, + }, + ], + }; + } catch (error) { + throw new Error(`Failed to get GraphQL schema: ${error}`); + } }); server.tool( - "introspect-schema", - "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.", - { - // This is a workaround to help clients that can't handle an empty object as an argument - // They will often send undefined instead of an empty object which is not allowed by the schema - __ignore__: z - .boolean() - .default(false) - .describe("This does not do anything"), - }, - async () => { - try { - let schema: string; - if (env.SCHEMA) { - schema = await introspectLocalSchema(env.SCHEMA); - } else { - schema = await introspectEndpoint(env.ENDPOINT, env.HEADERS); - } - - return { - content: [ - { - type: "text", - text: schema, - }, - ], - }; - } catch (error) { - return { - isError: true, - content: [ - { - type: "text", - text: `Failed to introspect schema: ${error}`, - }, - ], - }; - } - }, + "introspect-schema", + "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.", + { + // This is a workaround to help clients that can't handle an empty object as an argument + // They will often send undefined instead of an empty object which is not allowed by the schema + __ignore__: z + .boolean() + .default(false) + .describe("This does not do anything"), + }, + async () => { + try { + let schema: string; + if (env.SCHEMA) { + schema = await introspectLocalSchema(env.SCHEMA); + } else { + schema = await introspectEndpoint(env.ENDPOINT, env.HEADERS); + } + + return { + content: [ + { + type: "text", + text: schema, + }, + ], + }; + } catch (error) { + return { + isError: true, + content: [ + { + type: "text", + text: `Failed to introspect schema: ${error}`, + }, + ], + }; + } + }, ); server.tool( - "query-graphql", - "Query a GraphQL endpoint with the given query and variables", - { - query: z.string(), - variables: z.string().optional(), - }, - async ({ query, variables }) => { - try { - const parsedQuery = parse(query); - - // Check if the query is a mutation - const isMutation = parsedQuery.definitions.some( - (def) => - def.kind === "OperationDefinition" && def.operation === "mutation", - ); - - if (isMutation && !env.ALLOW_MUTATIONS) { - return { - isError: true, - content: [ - { - type: "text", - text: "Mutations are not allowed unless you enable them in the configuration. Please use a query operation instead.", - }, - ], - }; - } - } catch (error) { - return { - isError: true, - content: [ - { - type: "text", - text: `Invalid GraphQL query: ${error}`, - }, - ], - }; - } - - try { - const response = await fetch(env.ENDPOINT, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...env.HEADERS, - }, - body: JSON.stringify({ - query, - variables, - }), - }); - - if (!response.ok) { - const responseText = await response.text(); - - return { - isError: true, - content: [ - { - type: "text", - text: `GraphQL request failed: ${response.statusText}\n${responseText}`, - }, - ], - }; - } - - const data = await response.json(); - - if (data.errors && data.errors.length > 0) { - // Contains GraphQL errors - return { - isError: true, - content: [ - { - type: "text", - text: `The GraphQL response has errors, please fix the query: ${JSON.stringify( - data, - null, - 2, - )}`, - }, - ], - }; - } - - return { - content: [ - { - type: "text", - text: JSON.stringify(data, null, 2), - }, - ], - }; - } catch (error) { - throw new Error(`Failed to execute GraphQL query: ${error}`); - } - }, + "query-graphql", + "Query a GraphQL endpoint with the given query and variables", + { + query: z.string(), + variables: z.string().optional(), + }, + async ({query, variables}) => { + try { + const parsedQuery = parse(query); + + // Check if the query is a mutation + const isMutation = parsedQuery.definitions.some( + (def) => + def.kind === "OperationDefinition" && def.operation === "mutation", + ); + + if (isMutation && !env.ALLOW_MUTATIONS) { + return { + isError: true, + content: [ + { + type: "text", + text: "Mutations are not allowed unless you enable them in the configuration. Please use a query operation instead.", + }, + ], + }; + } + } catch (error) { + return { + isError: true, + content: [ + { + type: "text", + text: `Invalid GraphQL query: ${error}`, + }, + ], + }; + } + + try { + const response = await fetch(env.ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...env.HEADERS, + }, + body: JSON.stringify({ + query, + variables, + }), + }); + + if (!response.ok) { + const responseText = await response.text(); + + return { + isError: true, + content: [ + { + type: "text", + text: `GraphQL request failed: ${response.statusText}\n${responseText}`, + }, + ], + }; + } + + const data = await response.json(); + + if (data.errors && data.errors.length > 0) { + // Contains GraphQL errors + return { + isError: true, + content: [ + { + type: "text", + text: `The GraphQL response has errors, please fix the query: ${JSON.stringify( + data, + null, + 2, + )}`, + }, + ], + }; + } + + return { + content: [ + { + type: "text", + text: JSON.stringify(data, null, 2), + }, + ], + }; + } catch (error) { + throw new Error(`Failed to execute GraphQL query: ${error}`); + } + }, ); async function main() { - const transport = new StdioServerTransport(); - await server.connect(transport); + const transport = new StdioServerTransport(); + await server.connect(transport); - console.error( - `Started graphql mcp server ${env.NAME} for endpoint: ${env.ENDPOINT}`, - ); + console.error( + `Started graphql mcp server ${env.NAME} for endpoint: ${env.ENDPOINT}`, + ); } main().catch((error) => { - console.error(`Fatal error in main(): ${error}`); - process.exit(1); + console.error(`Fatal error in main(): ${error}`); + process.exit(1); });