graphql-mcp
Version:
A Model Context Protocol server that enables LLMs to interact with GraphQL APIs through dynamic schema introspection and query execution
702 lines • 26.9 kB
JavaScript
/**
* GraphQL MCP Server v1.1
*
* A Model Context Protocol server that enables Large Language Models to
* dynamically discover and interact with GraphQL APIs through schema
* introspection and secure query execution.
*
* This server provides four main tools:
* - configure_graphql: Set up connection to any GraphQL endpoint
* - introspect_schema: Discover all available resolvers, types, and fields
* - execute_query: Execute GraphQL queries with variables and security constraints
* - get_status: Check current configuration and connection status
*
* @author noamski
* @license MIT
* @version 1.1.0
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
import { GraphQLClient } from 'graphql-request';
import { getIntrospectionQuery } from 'graphql';
import { z } from 'zod';
/**
* Configuration schema for GraphQL endpoint setup
* Validates and provides defaults for all configuration options
*/
const ConfigureArgsSchema = z.object({
endpoint: z.string().url('Must be a valid GraphQL endpoint URL').optional(),
headers: z.record(z.string()).optional().describe('HTTP headers for authentication (e.g., Authorization)'),
timeout: z.number().min(1000).max(60000).default(30000).describe('Request timeout in milliseconds'),
maxDepth: z.number().min(1).max(20).default(10).describe('Maximum allowed query nesting depth'),
maxComplexity: z.number().min(1).max(1000).default(100).describe('Maximum allowed query complexity score'),
disabledResolvers: z.array(z.string()).default([]).describe('List of resolver names to disable for security'),
});
/**
* Schema for GraphQL query execution arguments
*/
const QueryArgsSchema = z.object({
query: z.string().min(1, 'GraphQL query cannot be empty'),
variables: z.record(z.unknown()).optional().describe('Variables to pass to the GraphQL query'),
operationName: z.string().optional().describe('Name of the operation to execute (for multi-operation documents)'),
});
/**
* Schema for schema introspection arguments
*/
const IntrospectArgsSchema = z.object({
includeDeprecated: z.boolean().default(false).describe('Include deprecated fields and enum values in the schema'),
});
/**
* Global server state
* Maintains connection info and cached data across requests
*/
const state = {
client: null,
endpoint: null,
config: null,
cachedSchema: null,
schemaTimestamp: 0,
dynamicTools: [],
};
/**
* Cache TTL for schema introspection results (5 minutes)
* Schema introspection is expensive, so we cache results to improve performance
*/
const SCHEMA_CACHE_TTL = 5 * 60 * 1000;
/**
* Load GraphQL configuration from environment variables
* Allows the server to work out-of-the-box with environment-based configuration
*
* @returns Partial configuration object from environment variables
*/
function loadEnvironmentConfig() {
const config = {};
if (process.env.GRAPHQL_ENDPOINT) {
config.endpoint = process.env.GRAPHQL_ENDPOINT;
}
if (process.env.GRAPHQL_TIMEOUT) {
const timeout = parseInt(process.env.GRAPHQL_TIMEOUT, 10);
if (!isNaN(timeout)) {
config.timeout = timeout;
}
}
if (process.env.GRAPHQL_MAX_DEPTH) {
const maxDepth = parseInt(process.env.GRAPHQL_MAX_DEPTH, 10);
if (!isNaN(maxDepth)) {
config.maxDepth = maxDepth;
}
}
if (process.env.GRAPHQL_MAX_COMPLEXITY) {
const maxComplexity = parseInt(process.env.GRAPHQL_MAX_COMPLEXITY, 10);
if (!isNaN(maxComplexity)) {
config.maxComplexity = maxComplexity;
}
}
if (process.env.GRAPHQL_DISABLED_RESOLVERS) {
config.disabledResolvers = process.env.GRAPHQL_DISABLED_RESOLVERS.split(',').map(r => r.trim());
}
// Parse headers from environment (JSON format)
if (process.env.GRAPHQL_HEADERS) {
try {
config.headers = JSON.parse(process.env.GRAPHQL_HEADERS);
}
catch (error) {
logToStderr('Failed to parse GRAPHQL_HEADERS as JSON:', error);
}
}
// Common authentication headers
if (process.env.GRAPHQL_AUTH_TOKEN) {
config.headers = config.headers || {};
config.headers['Authorization'] = `Bearer ${process.env.GRAPHQL_AUTH_TOKEN}`;
}
if (process.env.GRAPHQL_API_KEY) {
config.headers = config.headers || {};
config.headers['X-API-Key'] = process.env.GRAPHQL_API_KEY;
}
return config;
}
/**
* Convert GraphQL type to JSON Schema type
* Handles scalar types, enums, and object references
*
* @param gqlType - GraphQL type definition
* @returns JSON Schema type definition
*/
function convertGraphQLTypeToJsonSchema(gqlType) {
// Handle non-null wrapper
if (gqlType.kind === 'NON_NULL') {
return convertGraphQLTypeToJsonSchema(gqlType.ofType);
}
// Handle list wrapper
if (gqlType.kind === 'LIST') {
return {
type: 'array',
items: convertGraphQLTypeToJsonSchema(gqlType.ofType)
};
}
// Handle scalar and enum types
if (gqlType.kind === 'SCALAR') {
switch (gqlType.name) {
case 'String': return { type: 'string' };
case 'Int': return { type: 'integer' };
case 'Float': return { type: 'number' };
case 'Boolean': return { type: 'boolean' };
case 'ID': return { type: 'string' };
default: return { type: 'string' }; // Fallback for custom scalars
}
}
if (gqlType.kind === 'ENUM') {
return {
type: 'string',
enum: gqlType.enumValues?.map((v) => v.name) || []
};
}
// For object types, just reference by name
return { type: 'string', description: `${gqlType.name} object` };
}
/**
* Create MCP tools from GraphQL schema
* Converts each query and mutation resolver into an individual MCP tool
*
* @param schema - GraphQL introspection schema
* @returns Array of MCP tool definitions
*/
function createMCPToolsFromSchema(schema) {
const tools = [];
const schemaData = schema.__schema;
// Process Query type
if (schemaData.queryType) {
const queryType = schemaData.types.find((t) => t.name === schemaData.queryType.name);
if (queryType?.fields) {
for (const field of queryType.fields) {
if (state.config?.disabledResolvers.includes(field.name)) {
continue; // Skip disabled resolvers
}
const properties = {};
const required = [];
// Convert field arguments to JSON Schema properties
if (field.args) {
for (const arg of field.args) {
properties[arg.name] = convertGraphQLTypeToJsonSchema(arg.type);
if (arg.type.kind === 'NON_NULL') {
required.push(arg.name);
}
if (arg.description) {
properties[arg.name].description = arg.description;
}
}
}
tools.push({
name: `query_${field.name}`,
description: field.description || `Execute GraphQL query: ${field.name}`,
inputSchema: {
type: 'object',
properties,
required,
additionalProperties: false
},
resolver: {
type: 'query',
fieldName: field.name,
returnType: getLeafType(field.type).name || 'Mixed',
args: field.args || []
}
});
}
}
}
// Process Mutation type
if (schemaData.mutationType) {
const mutationType = schemaData.types.find((t) => t.name === schemaData.mutationType.name);
if (mutationType?.fields) {
for (const field of mutationType.fields) {
if (state.config?.disabledResolvers.includes(field.name)) {
continue; // Skip disabled resolvers
}
const properties = {};
const required = [];
// Convert field arguments to JSON Schema properties
if (field.args) {
for (const arg of field.args) {
properties[arg.name] = convertGraphQLTypeToJsonSchema(arg.type);
if (arg.type.kind === 'NON_NULL') {
required.push(arg.name);
}
if (arg.description) {
properties[arg.name].description = arg.description;
}
}
}
tools.push({
name: `mutation_${field.name}`,
description: field.description || `Execute GraphQL mutation: ${field.name}`,
inputSchema: {
type: 'object',
properties,
required,
additionalProperties: false
},
resolver: {
type: 'mutation',
fieldName: field.name,
returnType: getLeafType(field.type).name || 'Mixed',
args: field.args || []
}
});
}
}
}
return tools;
}
/**
* Generate a basic selection set for GraphQL object types
* Uses the cached schema to find actual scalar fields for the type
*
* @param returnType - The GraphQL return type name
* @returns Selection set string like " { name code }" or empty string for scalars
*/
function getBasicSelectionSet(returnType) {
// For scalar types, no selection set needed
const scalarTypes = ['String', 'Int', 'Float', 'Boolean', 'ID'];
if (scalarTypes.includes(returnType) || returnType === 'Mixed') {
return '';
}
// If we have cached schema, try to get actual fields for this type
if (state.cachedSchema) {
const schema = state.cachedSchema.__schema;
if (schema?.types) {
const type = schema.types.find((t) => t.name === returnType);
if (type?.fields) {
// Get first few scalar fields from the type
const scalarFields = type.fields
.filter((field) => {
const fieldType = getLeafType(field.type);
return scalarTypes.includes(fieldType.name) || fieldType.kind === 'ENUM';
})
.slice(0, 5) // Limit to first 5 fields to avoid complexity
.map((field) => field.name);
if (scalarFields.length > 0) {
return ` { ${scalarFields.join(' ')} }`;
}
}
}
}
// Fallback to common field names
const commonFields = ['id', 'name', 'code', 'title'];
return ` { ${commonFields.join(' ')} }`;
}
/**
* Get the leaf type from a GraphQL type (unwrap NON_NULL and LIST wrappers)
*/
function getLeafType(type) {
if (type.kind === 'NON_NULL' || type.kind === 'LIST') {
return getLeafType(type.ofType);
}
return type;
}
/**
* Convert GraphQL type definition to GraphQL type string for variables
* Examples: "String!", "[String!]!", "ID"
*/
function getGraphQLTypeString(type) {
if (type.kind === 'NON_NULL') {
return getGraphQLTypeString(type.ofType) + '!';
}
if (type.kind === 'LIST') {
return `[${getGraphQLTypeString(type.ofType)}]`;
}
return type.name;
}
/**
* Logging utility that writes to stderr (never stdout for stdio transport)
* Following MCP best practices for stdio-based servers
*
* @param message - Log message
* @param args - Additional arguments to log
*/
function logToStderr(message, ...args) {
console.error(`[GraphQL-MCP] ${new Date().toISOString()}: ${message}`, ...args);
}
/**
* Calculate the complexity score of a GraphQL query
* Complexity is measured by counting the number of fields requested
*
* @param query - GraphQL query string
* @returns Complexity score (number of fields)
*/
function calculateQueryComplexity(query) {
// Simple complexity calculation based on field count
// This counts field selections in the query
const fieldMatches = query.match(/\w+(?=\s*[{(])/g);
return fieldMatches ? fieldMatches.length : 0;
}
/**
* Calculate the maximum nesting depth of a GraphQL query
* Depth limiting prevents excessively nested queries that could cause performance issues
*
* @param query - GraphQL query string
* @returns Maximum nesting depth
*/
function calculateQueryDepth(query) {
let depth = 0;
let maxDepth = 0;
// Count nesting level by tracking opening and closing braces
for (const char of query) {
if (char === '{') {
depth++;
maxDepth = Math.max(maxDepth, depth);
}
else if (char === '}') {
depth--;
}
}
return maxDepth;
}
/**
* Sanitize a GraphQL query by removing comments and normalizing whitespace
* This helps with consistent processing and security
*
* @param query - Raw GraphQL query string
* @returns Sanitized query string
*/
function sanitizeQuery(query) {
return query
.replace(/\/\*[\s\S]*?\*\//g, '') // Remove block comments /* */
.replace(/#[^\r\n]*/g, '') // Remove line comments #
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
}
/**
* Validate a GraphQL query against security constraints
* Throws McpError if validation fails
*
* @param query - GraphQL query to validate
* @param config - Server configuration with security settings
* @throws {McpError} If query violates security constraints
*/
function validateQueryConstraints(query, config) {
const sanitized = sanitizeQuery(query);
// Check query complexity
const complexity = calculateQueryComplexity(sanitized);
if (complexity > config.maxComplexity) {
throw new McpError(ErrorCode.InvalidParams, `Query complexity (${complexity}) exceeds maximum allowed (${config.maxComplexity})`);
}
// Check query depth
const depth = calculateQueryDepth(sanitized);
if (depth > config.maxDepth) {
throw new McpError(ErrorCode.InvalidParams, `Query depth (${depth}) exceeds maximum allowed (${config.maxDepth})`);
}
// Check for disabled resolvers
for (const resolver of config.disabledResolvers) {
if (sanitized.toLowerCase().includes(resolver.toLowerCase())) {
throw new McpError(ErrorCode.InvalidParams, `Access to resolver '${resolver}' is disabled`);
}
}
}
/**
* Create and configure the MCP server instance
* Declares server capabilities and metadata
*/
const server = new Server({
name: 'graphql-mcp',
version: '1.1.0',
}, {
capabilities: {
tools: {}, // This server provides tools for GraphQL operations
},
});
/**
* Handle the tools/list request
* Returns all available tools that this server provides
*/
server.setRequestHandler(ListToolsRequestSchema, async () => {
// Only show status tool and dynamic GraphQL resolver tools to LLMs
const staticTools = [
{
name: 'get_status',
description: 'Get current GraphQL connection status and configuration',
inputSchema: {
type: 'object',
properties: {},
},
},
];
// Add dynamic GraphQL resolver tools
const dynamicTools = state.dynamicTools.map(tool => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
}));
return {
tools: [...staticTools, ...dynamicTools],
};
});
/**
* Handle tool execution requests
* Routes to the appropriate handler based on tool name
*/
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
// Handle static tools
switch (name) {
case 'get_status':
return await handleGetStatus();
}
// Handle dynamic GraphQL resolver tools
const dynamicTool = state.dynamicTools.find(tool => tool.name === name);
if (dynamicTool) {
return await handleGraphQLResolver(dynamicTool, args);
}
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}. Available tools: ${['get_status', ...state.dynamicTools.map(t => t.name)].join(', ')}`);
}
catch (error) {
// Re-throw MCP errors as-is
if (error instanceof McpError) {
throw error;
}
// Log unexpected errors and wrap them
logToStderr(`Tool execution failed for ${name}:`, error);
throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
});
/**
* Handle GraphQL endpoint configuration
* Tests the connection and stores configuration if successful
*
* @param args - Configuration arguments
* @returns Success message with configuration details
*/
async function handleConfigureGraphQL(args) {
// Load environment configuration as base
const envConfig = loadEnvironmentConfig();
// Parse and merge with provided arguments
const parsedArgs = ConfigureArgsSchema.parse(args);
const config = {
...envConfig,
...parsedArgs,
// Merge headers specifically to avoid overwriting
headers: {
...envConfig.headers,
...parsedArgs.headers
}
};
// Endpoint is required either from args or environment
if (!config.endpoint) {
throw new McpError(ErrorCode.InvalidParams, 'GraphQL endpoint must be provided either as argument or via GRAPHQL_ENDPOINT environment variable');
}
// Test connection to the GraphQL endpoint
const testClient = new GraphQLClient(config.endpoint, {
headers: config.headers || {}
});
try {
// Verify the endpoint responds to a simple query
await testClient.request('{ __typename }');
}
catch (error) {
throw new McpError(ErrorCode.InternalError, `Failed to connect to GraphQL endpoint: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Store configuration in server state
state.client = testClient;
state.endpoint = config.endpoint;
state.config = config;
state.cachedSchema = null; // Clear any cached schema
state.schemaTimestamp = 0;
state.dynamicTools = []; // Clear dynamic tools
// Perform schema introspection and create dynamic tools
try {
logToStderr('Performing schema introspection to create resolver tools');
const introspectionQuery = getIntrospectionQuery();
const schemaResult = await testClient.request(introspectionQuery);
// Cache the schema
state.cachedSchema = schemaResult;
state.schemaTimestamp = Date.now();
// Create dynamic MCP tools for each GraphQL resolver
state.dynamicTools = createMCPToolsFromSchema(schemaResult);
logToStderr(`Created ${state.dynamicTools.length} dynamic tools from GraphQL schema`);
}
catch (error) {
logToStderr('Schema introspection failed, but configuration saved:', error);
}
logToStderr(`Configured GraphQL endpoint: ${config.endpoint}`);
return {
content: [
{
type: 'text',
text: `✅ Successfully configured GraphQL endpoint: ${config.endpoint}\n\nSettings:\n- Max depth: ${config.maxDepth}\n- Max complexity: ${config.maxComplexity}\n- Disabled resolvers: ${config.disabledResolvers.length > 0 ? config.disabledResolvers.join(', ') : 'None'}\n- Timeout: ${config.timeout}ms\n- Dynamic tools created: ${state.dynamicTools.length}`,
},
],
};
}
/**
* Handle execution of dynamic GraphQL resolver tools
* Converts MCP tool calls into GraphQL queries and executes them
*
* @param tool - The dynamic tool definition
* @param args - Arguments passed to the tool
* @returns GraphQL query results
*/
async function handleGraphQLResolver(tool, args) {
// Ensure GraphQL client is configured
if (!state.client || !state.config) {
const envConfig = loadEnvironmentConfig();
const suggestion = envConfig.endpoint
? 'Use configure_graphql tool to activate the endpoint, or check server logs for auto-configuration errors.'
: 'Use configure_graphql tool first, or set GRAPHQL_ENDPOINT environment variable.';
throw new McpError(ErrorCode.InvalidRequest, `GraphQL endpoint not configured. ${suggestion}`);
}
// Validate arguments against the tool's input schema
const parsedArgs = args;
// Build GraphQL query from tool definition and arguments
const { resolver } = tool;
const operationType = resolver.type === 'mutation' ? 'mutation' : 'query';
// Build arguments string for the GraphQL query
let argsString = '';
let variablesString = '';
const variables = {};
if (parsedArgs && Object.keys(parsedArgs).length > 0) {
const argPairs = [];
const varPairs = [];
for (const [key, value] of Object.entries(parsedArgs)) {
argPairs.push(`${key}: $${key}`);
// Find the actual GraphQL type for this argument
const arg = tool.resolver.args.find((a) => a.name === key);
const gqlType = arg ? getGraphQLTypeString(arg.type) : 'String';
varPairs.push(`$${key}: ${gqlType}`);
variables[key] = value;
}
if (argPairs.length > 0) {
argsString = `(${argPairs.join(', ')})`;
variablesString = `(${varPairs.join(', ')})`;
}
}
// Add basic selection set for object types
const selectionSet = getBasicSelectionSet(resolver.returnType);
const query = `${operationType} ${variablesString} {
${resolver.fieldName}${argsString}${selectionSet}
}`;
try {
// Validate query against security constraints
validateQueryConstraints(query, state.config);
logToStderr(`Executing GraphQL ${resolver.type}: ${resolver.fieldName}`, {
hasVariables: Object.keys(variables).length > 0,
});
// Execute the GraphQL query
const result = await state.client.request(query, variables);
return {
content: [
{
type: 'text',
text: `✅ ${resolver.type === 'mutation' ? 'Mutation' : 'Query'} executed: ${resolver.fieldName}\n\nEndpoint: ${state.endpoint}\n\nResult:\n${JSON.stringify(result, null, 2)}`,
},
],
};
}
catch (error) {
logToStderr(`GraphQL ${resolver.type} execution failed:`, error);
// Re-throw validation errors
if (error instanceof McpError) {
throw error;
}
// Handle GraphQL execution errors gracefully
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return {
content: [
{
type: 'text',
text: `❌ ${resolver.type === 'mutation' ? 'Mutation' : 'Query'} failed: ${resolver.fieldName}\n\nError: ${errorMessage}\n\nQuery:\n${query}`,
},
],
isError: true,
};
}
}
/**
* Handle status check requests
* Returns current server configuration and connection status
*
* @returns Current server status information
*/
async function handleGetStatus() {
const status = {
configured: !!state.client,
endpoint: state.endpoint,
settings: state.config ? {
maxDepth: state.config.maxDepth,
maxComplexity: state.config.maxComplexity,
disabledResolvers: state.config.disabledResolvers,
timeout: state.config.timeout,
headersConfigured: !!state.config.headers && Object.keys(state.config.headers).length > 0,
} : null,
cacheStatus: {
schemaCache: !!state.cachedSchema,
cacheAge: state.cachedSchema ? Date.now() - state.schemaTimestamp : null,
cacheTTL: SCHEMA_CACHE_TTL,
},
};
return {
content: [
{
type: 'text',
text: `📊 GraphQL MCP Server Status\n\n${JSON.stringify(status, null, 2)}`,
},
],
};
}
/**
* Main server startup function
* Initializes the MCP server with stdio transport and sets up error handling
*/
async function main() {
logToStderr('GraphQL MCP Server v1.1.0 starting');
logToStderr('Protocol version: 2025-06-18');
logToStderr('Transport: stdio');
logToStderr('Capabilities: tools');
// Connect to transport first
const transport = new StdioServerTransport();
await server.connect(transport);
logToStderr('GraphQL MCP Server v1.1.0 ready');
// Auto-configure from environment AFTER connecting (non-blocking)
const envConfig = loadEnvironmentConfig();
if (envConfig.endpoint) {
// Use setTimeout to avoid blocking the connection
setTimeout(async () => {
try {
await handleConfigureGraphQL({});
logToStderr(`Auto-configured from environment: ${envConfig.endpoint}`);
}
catch (error) {
logToStderr('Auto-configuration from environment failed:', error);
logToStderr('Server running without GraphQL configuration');
}
}, 100);
}
else {
logToStderr('No GRAPHQL_ENDPOINT found in environment. Server running without GraphQL configuration');
logToStderr('Configure via environment variables: GRAPHQL_ENDPOINT, GRAPHQL_AUTH_TOKEN, etc.');
}
}
/**
* Graceful shutdown handlers
* Ensure clean server shutdown on process signals
*/
process.on('SIGINT', async () => {
logToStderr('Received SIGINT, shutting down...');
await server.close();
process.exit(0);
});
process.on('SIGTERM', async () => {
logToStderr('Received SIGTERM, shutting down...');
await server.close();
process.exit(0);
});
/**
* Entry point - always start the server when this module is executed
*/
main().catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
});
//# sourceMappingURL=server.js.map