diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..c7033ad Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json index 1f171cb..5ecfdac 100644 --- a/package.json +++ b/package.json @@ -1,34 +1,33 @@ { - "name": "mcp-graphql", - "module": "index.ts", - "type": "module", - "version": "2.0.1", - "repository": "github:blurrah/mcp-graphql", - "license": "MIT", - "bin": { - "mcp-graphql": "./dist/index.js" - }, - "files": [ - "dist" - ], - "devDependencies": { - "@graphql-tools/schema": "^10.0.21", - "@types/bun": "latest", - "@types/yargs": "17.0.33", - "graphql-yoga": "^5.13.1", - "typescript": "5.8.2" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "1.6.1", - "graphql": "^16.10.0", - "yargs": "17.7.2", - "zod": "3.24.2", - "zod-to-json-schema": "3.24.3" - }, - "scripts": { - "dev": "bun --watch src/index.ts", - "build": "bun build src/index.ts --outdir dist --target node && bun -e \"require('fs').chmodSync('dist/index.js', '755')\"", - "start": "bun run dist/index.js" - }, - "packageManager": "bun@1.2.4" + "name": "mcp-graphql", + "module": "index.ts", + "type": "module", + "version": "2.0.1", + "repository": "github:blurrah/mcp-graphql", + "license": "MIT", + "bin": { + "mcp-graphql": "./dist/index.js" + }, + "files": ["dist"], + "devDependencies": { + "@graphql-tools/schema": "^10.0.21", + "@types/bun": "latest", + "@types/yargs": "17.0.33", + "graphql-yoga": "^5.13.1", + "typescript": "5.8.2" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "1.6.1", + "graphql": "^16.10.0", + "yargs": "17.7.2", + "zod": "3.24.2", + "zod-to-json-schema": "3.24.3" + }, + "scripts": { + "dev": "bun --watch src/index.ts", + "dev:new": "bun --watch src/server.ts", + "build": "bun build src/index.ts --outdir dist --target node && bun -e \"require('fs').chmodSync('dist/index.js', '755')\"", + "start": "bun run dist/index.js" + }, + "packageManager": "bun@1.2.4" } diff --git a/schema-simple.graphql b/schema-simple.graphql new file mode 100644 index 0000000..d061272 --- /dev/null +++ b/schema-simple.graphql @@ -0,0 +1,91 @@ +schema { + query: Query + mutation: Mutation +} + +type Query { + """Get a user by ID""" + user(id: ID!): User + + """Get all users""" + users: [User!]! + + """Get a post by ID""" + post(id: ID!): Post + + """Get all posts""" + posts: [Post!]! + + """Get all comments for a post""" + commentsByPost(postId: ID!): [Comment!]! +} + +type Mutation { + """Create a new user""" + createUser(input: CreateUserInput!): User! + + """Update an existing user""" + updateUser(id: ID!, input: UpdateUserInput!): User! + + """Delete a user""" + deleteUser(id: ID!): Boolean! + + """Create a new post""" + createPost(input: CreatePostInput!): Post! + + """Add a comment to a post""" + addComment(input: AddCommentInput!): Comment! +} + +type User { + id: ID! + name: String! + email: String! + posts: [Post!]! + comments: [Comment!]! + createdAt: String! + updatedAt: String +} + +type Post { + id: ID! + title: String! + content: String! + published: Boolean! + author: User! + comments: [Comment!]! + createdAt: String! + updatedAt: String +} + +type Comment { + id: ID! + text: String! + post: Post! + author: User! + createdAt: String! +} + +input CreateUserInput { + name: String! + email: String! +} + +input UpdateUserInput { + name: String + email: String +} + +input CreatePostInput { + title: String! + content: String! + published: Boolean + authorId: ID! +} + +input AddCommentInput { + text: String! + postId: ID! + authorId: ID! +} + diff --git a/src/index.ts b/src/index.ts index 2d3646e..055d83a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -199,6 +199,9 @@ server.tool( }, ); +/** + * Sets up the transport and starts the server with it + */ async function main() { const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..ae5eca9 --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,69 @@ +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { z } from "zod"; + +export const configSchema = z.object({ + name: z.string().default("mcp-graphql"), + // Endpoint for the schema to be introspected and transformed into tools + endpoint: z.string().url(), + // File path alternative to endpoint, will read the file instead of fetching the endpoint + schemaPath: z.string().optional(), + // Headers to be sent with the request to the schema endpoint + headers: z.record(z.string()).optional(), + // Allow MCP clients to use mutations, can potentially be dangerous so we disable by default + allowMutations: z.boolean().optional().default(false), + // Queries to exclude from the generated tools + excludeQueries: z.array(z.string()).optional().default([]), + // Mutations to exclude from the generated tools + excludeMutations: z.array(z.string()).optional().default([]), +}); + +export type Config = z.infer; + +export function parseArgumentsToConfig(): Config { + const argv = yargs(hideBin(process.argv)) + .option("name", { + type: "string", + description: + "Name of the MCP server, can be used if you want to override the default name", + }) + .option("endpoint", { + type: "string", + description: + "Endpoint for the schema to be introspected and transformed into tools", + }) + .option("schemaPath", { + type: "string", + description: + "Alternative path for GraphQL schema file, use this if you cannot introspect the schema from the endpoint", + }) + .option("headers", { + type: "string", + description: + "JSON stringified headers to be sent with the request to the schema endpoint", + default: "{}", + }) + .option("allowMutations", { + type: "boolean", + description: + "Allow MCP clients to use mutations, can potentially be dangerous so we disable by default", + }) + .option("excludeQueries", { + type: "array", + description: "Queries to exclude from the generated tools", + }) + .option("excludeMutations", { + type: "array", + description: "Mutations to exclude from the generated tools", + }) + .help() + .parseSync(); + + const parsedArgs = { + ...argv, + headers: argv.headers ? JSON.parse(argv.headers) : undefined, + }; + + // Just let this throw, will catch it during main execution + return configSchema.parse(parsedArgs); +} diff --git a/src/lib/graphql.ts b/src/lib/graphql.ts new file mode 100644 index 0000000..9e9599b --- /dev/null +++ b/src/lib/graphql.ts @@ -0,0 +1,429 @@ +// Contains Schema parsing and transformation logic + +import { + type GraphQLArgument, + type GraphQLInputType, + GraphQLNonNull, + type GraphQLOutputType, + type GraphQLSchema, + buildClientSchema, + buildSchema, + getIntrospectionQuery, + isInputObjectType, + isListType, + isNonNullType, + isScalarType, + printSchema, +} from "graphql"; +import { readFile, writeFile } from "node:fs/promises"; +import { z } from "zod"; +import zodToJsonSchema, { type JsonSchema7Type } from "zod-to-json-schema"; +import type { Config } from "./config"; + +export async function loadSchemaFromIntrospection( + endpoint: string, + headers?: Record, +): Promise { + const response = await fetch(endpoint, { + headers: { + "Content-Type": "application/json", + ...headers, + }, + method: "POST", + body: JSON.stringify({ + query: getIntrospectionQuery(), + }), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch GraphQL schema: ${response.statusText}`); + } + + const responseJson = await response.json(); + + if (responseJson.errors) { + throw new Error( + `Failed to fetch GraphQL schema: ${JSON.stringify(responseJson.errors)}`, + ); + } + + if (!responseJson.data.__schema) { + throw new Error(`Invalid schema found at ${JSON.stringify(responseJson)}`); + } + + const schemaObj = buildClientSchema(responseJson.data); + + const sdl = printSchema(schemaObj); + + // Debug code to not rate limit the endpoint: + await writeFile("schema.graphql", sdl); + + return schemaObj; +} + +export async function loadSchemaFromFile(path: string): Promise { + const data = await readFile(path, "utf-8"); + + return buildSchema(data); +} + +type Operation = { + name: string; + type: "query" | "mutation"; + description: string | undefined | null; + parameters: readonly GraphQLArgument[]; +}; + +/** + * Extracts all operations from a GraphQL schema and return them in a structured format + * @param schema - The GraphQL schema to extract operations from + * @returns An array of operations + */ +export function getOperations( + schema: GraphQLSchema, + // Subscriptions are not supported (yet?) + allowedOperations: ("query" | "mutation")[] = ["query", "mutation"], +): Operation[] { + const operations: Operation[] = []; + + if (allowedOperations.includes("query")) { + const queryType = schema.getQueryType(); + const queryFields = queryType?.getFields(); + for (const [fieldName, field] of Object.entries(queryFields || {})) { + operations.push({ + name: fieldName, + type: "query", + // TODO: Add all the possibly output types to the description + description: createOperationDescription(schema, { + name: fieldName, + type: "query", + parameters: field.args, + description: field.description, + } satisfies Operation), + parameters: field.args, + }); + } + } + + if (allowedOperations.includes("mutation")) { + const mutationType = schema.getMutationType(); + const mutationFields = mutationType?.getFields(); + for (const [fieldName, field] of Object.entries(mutationFields || {})) { + operations.push({ + name: fieldName, + type: "mutation", + description: createOperationDescription(schema, { + name: fieldName, + type: "mutation", + parameters: field.args, + description: field.description, + } satisfies Operation), + parameters: field.args, + }); + } + } + + console.error(operations.length); + return operations; +} + +type Tool = { + name: string; + description: string; + parameters: z.ZodTypeAny; + inputSchema: JsonSchema7Type; +}; + +/** + * Converts a GraphQL operation to a MCP tool object + * @param operation - The GraphQL operation to convert + * @returns A MCP tool object + */ +export function operationToTool(operation: Operation): Tool { + // Import necessary types if they're not already imported + + if (!operation.name) { + // Should never reach this as we already filter out operations without a name earlier + throw new Error("Operation name is required"); + } + + // Create a name for the tool based on the operation + const name = `${operation.type}-${operation.name}`; + + // Get description from the operation or use a default + const description = operation.description; + + // Build parameters schema based on variable definitions + const paramSchema = buildZodSchemaFromVariables(operation.parameters); + + console.error(paramSchema); + + // Return the tool object + return { + name, + description: description || "", + parameters: paramSchema, + inputSchema: zodToJsonSchema(paramSchema), + }; +} + +/** + * Builds a Zod schema from GraphQL variable definitions + * @param variableDefinitions - The variable definitions from a GraphQL operation + * @returns A Zod schema object + */ +function buildZodSchemaFromVariables( + variableDefinitions: ReadonlyArray, +) { + const schemaObj: Record = {}; + + for (const definition of variableDefinitions) { + schemaObj[definition.name] = argumentToZodSchema(definition); + } + + return z.object({ + variables: z.object(schemaObj), + query: z.string(), + }); +} + +function argumentToZodSchema(argument: GraphQLArgument): z.ZodTypeAny { + // Build individual zod schema's + function convertToZodSchema( + type: GraphQLInputType, + maxDepth = 3, + ): z.ZodTypeAny { + if (maxDepth === 0) { + // Fall back to any type when we reach recursion limit, especially with circular references to input types this can get quite intensive + return z.any(); + } + + if (type instanceof GraphQLNonNull) { + // Non-null type, need to go deeper + return convertToZodSchema(type.ofType); + } + + if (isListType(type)) { + return z.array(convertToZodSchema(type.ofType)); + } + + if (isScalarType(type)) { + if (type.name === "String" || type.name === "ID") return z.string(); + if (type.name === "Int") return z.number().int(); + if (type.name === "Float") return z.number(); + if (type.name === "Boolean") return z.boolean(); + // Fall back to string for now when using custom scalars + return z.string(); + } + + if (isInputObjectType(type)) { + const fields = type.getFields(); + const shape: Record = {}; + for (const [fieldName, field] of Object.entries(fields)) { + shape[fieldName] = + field.type instanceof GraphQLNonNull + ? convertToZodSchema(field.type, maxDepth - 1) + : convertToZodSchema(field.type, maxDepth - 1).optional(); + } + + return z.object(shape).optional(); + } + + // Fall back to any type for now, hopefully extra input context will help an LLM with this + return z.any(); + } + + const zodField = convertToZodSchema(argument.type); + + // Default value is not part of the type, so we add it outside of the type converter + if (argument.defaultValue !== undefined) { + zodField.default(argument.defaultValue); + } + + return zodField; +} + +export async function createGraphQLHandler(config: Config) { + let schema: GraphQLSchema; + + if (config.schemaPath) { + schema = await loadSchemaFromFile(config.schemaPath); + } else { + // Fall back to introspection if no schema path is provided + schema = await loadSchemaFromIntrospection(config.endpoint, config.headers); + } + + const tools = new Map(); + + async function loadTools() { + const operations = getOperations( + schema, + config.allowMutations ? ["query", "mutation"] : ["query"], + ); + + // Add tools + for (const operation of operations) { + if ( + !operation.name || + config.excludeQueries.includes(operation.name) || + config.excludeMutations.includes(operation.name) + ) { + // Operation not found or excluded + console.error(`Skipping operation ${operation.name} as it is excluded`); + continue; + } + + const tool = operationToTool(operation); + + tools.set(tool.name, tool); + } + } + + // Load initial tools + await loadTools(); + + return { + tools, + loadTools, + async execute(query: string, variables: unknown) { + const body = JSON.stringify({ query, variables }); + console.error("body", body); + const result = await fetch(config.endpoint, { + method: "POST", + body, + }); + + console.error("result", await result.json()); + + return { + status: "success", + data: await result.json(), + }; + }, + }; +} + +/** + * Extracts the output type information from a GraphQL operation + * @param schema - The GraphQL schema + * @param operation - The GraphQL operation + * @returns A string representation of the output type structure + */ +function getOperationOutputType( + schema: GraphQLSchema, + operation: Operation, +): string { + const typeMap = schema.getTypeMap(); + let outputType: GraphQLOutputType | undefined; + + if (operation.type === "query") { + const queryType = schema.getQueryType(); + if (queryType) { + const field = queryType.getFields()[operation.name]; + if (field) { + outputType = field.type; + } + } + } else if (operation.type === "mutation") { + const mutationType = schema.getMutationType(); + if (mutationType) { + const field = mutationType.getFields()[operation.name]; + if (field) { + outputType = field.type; + } + } + } + + if (!outputType) { + return "Unknown output type"; + } + + // Generate a string representation of the output type + return printType(outputType, schema); +} + +/** + * Recursively prints a GraphQL type structure + * @param type - The GraphQL type to print + * @param schema - The GraphQL schema + * @param depth - Current recursion depth to prevent infinite loops + * @returns A string representation of the type + */ +function printType( + type: GraphQLOutputType, + schema: GraphQLSchema, + maxDepth = 5, +): string { + if (maxDepth === 0) return "..."; // Prevent too deep recursion, should I add it in text here? + + // Handle non-null and list wrappers + if ("ofType" in type) { + if (isListType(type)) { + return `[${printType(type.ofType, schema, maxDepth)}]`; + } + if (isNonNullType(type)) { + // Not sure why typescript goes to never typing here, need to check later + return `${printType( + (type as GraphQLNonNull).ofType, + schema, + maxDepth, + )}!`; + } + } + // Handle scalar types + if (isScalarType(type)) { + return type.name; + } + + // Handle enum types + if (type.astNode?.kind === "EnumTypeDefinition") { + return `ENUM ${type.name}`; + } + + // Handle object types + if ("getFields" in type && typeof type.getFields === "function") { + const fields = type.getFields(); + if (maxDepth - 1 === 0) { + // Return the type name if we are at the max depth already + return type.name; + } + const fieldStrings = Object.entries(fields).map(([name, field]) => { + return ` ${name}: ${printType(field.type, schema, maxDepth - 1)}`; + }); + + return `{\n${fieldStrings.join("\n")}\n}`; + } + + return "name" in type ? type.name : "Unknown"; +} + +function createOperationDescription( + schema: GraphQLSchema, + operation: Operation, +) { + const outputTypeInfo = getOperationOutputType(schema, operation); + return ` + ${operation.type} operation: "${operation.name}" + +DESCRIPTION: +${operation.description || "No description available"} + +PARAMETERS: +${ + operation.parameters.length > 0 + ? operation.parameters + .map( + (param) => + `- ${param.name}: ${param.type.toString()}${ + param.description ? ` - ${param.description}` : "" + }`, + ) + .join("\n") + : "No parameters required" +} + +OUTPUT TYPE: +${outputTypeInfo} + +When you use this operation, you'll receive a response with this structure.`; +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..4af5c1d --- /dev/null +++ b/src/server.ts @@ -0,0 +1,127 @@ +// Back to the original server implementation as it's more flexible for tool call generation + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import zodToJsonSchema from "zod-to-json-schema"; +import { getVersion } from "./helpers/package.js" with { type: "macro" }; +import { createGraphQLHandler } from "./lib/graphql"; + +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(), +}); + +const env = EnvSchema.parse(process.env); + +const server = new Server( + { + name: env.NAME, + version: getVersion(), + description: `GraphQL MCP server for ${env.ENDPOINT}`, + }, + { + capabilities: { + resources: {}, + tools: {}, + logging: {}, + }, + }, +); + +const handler = await createGraphQLHandler({ + endpoint: env.ENDPOINT, + headers: env.HEADERS, + allowMutations: env.ALLOW_MUTATIONS, + excludeQueries: [], + excludeMutations: [], + name: env.NAME, +}); + +// Post-rebase artifact +const tools = Array.from(handler.tools.values()).map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: tool.parameters, + inputSchema: tool.inputSchema, +})); + +// Post-rebase artifact +/** + * Handles tool calling from the client and executes the tool + */ +// async function handleToolCall(name: string, body: string, variables: string) { +// const tool = handler.getTool(name); +// if (!tool) { +// console.error(`Tool ${name} not found`); +// return { +// status: "error", +// message: `Tool ${name} not found`, +// }; +// } +// const result = await handler.execute(tool, body, variables); +// return result; +// } + +server.setRequestHandler(ListToolsRequestSchema, async (request) => { + return { + tools: [ + { + name: "introspect_schema", + description: + "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.", + inputSchema: zodToJsonSchema(z.object({})), + }, + { + // TODO: Check whether we should rename this to operation + name: "execute_query", + description: + "Query a GraphQL endpoint with the given query and variables", + parameters: z.object({ + query: z.string(), + variables: z.string().optional(), + }), + inputSchema: zodToJsonSchema( + z.object({ + query: z.string(), + variables: z.string().optional(), + }), + ), + }, + ...tools, + ], + }; +}); + +/** + * Sets up the transport and starts the server with it + */ +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + + 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); +});