GraphQL MCP Server
by ctkadvisors
Verified
- graphql-mcp
- src
#!/usr/bin/env node
import readline from "readline";
import { GraphQLClient } from "graphql-request";
import {
parse,
getIntrospectionQuery,
buildClientSchema,
GraphQLObjectType,
GraphQLList,
GraphQLNonNull,
isObjectType,
isListType,
isNonNullType,
isScalarType,
isEnumType,
isInputObjectType,
GraphQLSchema,
GraphQLArgument,
GraphQLField,
GraphQLType,
GraphQLOutputType,
GraphQLInputType,
GraphQLEnumType,
GraphQLInputObjectType,
GraphQLScalarType,
IntrospectionQuery,
} from "graphql";
// Type definitions for GraphQL MCP Server
// Configuration types
interface Config {
endpoint: string;
apiKey: string;
debug: boolean;
cacheTtl: number;
}
// Log data type
interface LogData {
[key: string]: any;
}
// Query execution args
interface QueryExecutionArgs {
query: string;
variables?: Record<string, any>;
endpoint?: string;
headers?: Record<string, string>;
}
// MCP Tool Definition
interface MCPTool {
name: string;
description: string;
inputSchema: {
type: string;
properties: Record<string, any>;
required: string[];
};
}
// JSON-RPC Request
interface JSONRPCRequest {
jsonrpc: string;
id: number | string | null;
method: string;
params?: any;
}
// JSON-RPC Response
interface JSONRPCResponse {
jsonrpc: string;
id: number | string | null;
result?: any;
error?: {
code: number;
message: string;
};
}
// Field representation for selection sets
type FieldSelection = string | { name: string; fields: FieldSelection[] };
// Configuration
const GRAPHQL_API_ENDPOINT: string =
process.env.GRAPHQL_API_ENDPOINT ||
"https://countries.trevorblades.com/graphql";
const GRAPHQL_API_KEY: string = process.env.GRAPHQL_API_KEY || "";
const DEBUG: boolean = process.env.DEBUG === "true";
const CACHE_TTL: number = 3600000; // 1 hour in milliseconds
const ENABLE_MUTATIONS: boolean = process.env.ENABLE_MUTATIONS === "true"; // Mutations are OFF by default
// Parse whitelisted queries from environment variable
let WHITELISTED_QUERIES: string[] | null = null;
if (process.env.WHITELISTED_QUERIES) {
try {
if (typeof process.env.WHITELISTED_QUERIES === "string") {
// Try parsing as JSON array first
if (process.env.WHITELISTED_QUERIES.startsWith("[")) {
WHITELISTED_QUERIES = JSON.parse(process.env.WHITELISTED_QUERIES);
} else {
// Otherwise treat as comma-separated list
WHITELISTED_QUERIES = process.env.WHITELISTED_QUERIES.split(",")
.map((q) => q.trim())
.filter(Boolean);
}
}
log(
"info",
`Loaded whitelist with ${
(WHITELISTED_QUERIES && WHITELISTED_QUERIES.length) || 0
} queries`,
{ whitelist: WHITELISTED_QUERIES }
);
} catch (error) {
log(
"error",
`Failed to parse WHITELISTED_QUERIES: ${
error instanceof Error ? error.message : String(error)
}`
);
// If parsing fails, don't use a whitelist
WHITELISTED_QUERIES = null;
}
}
// Parse whitelisted mutations from environment variable
let WHITELISTED_MUTATIONS: string[] | null = null;
if (process.env.WHITELISTED_MUTATIONS) {
try {
if (typeof process.env.WHITELISTED_MUTATIONS === "string") {
// Try parsing as JSON array first
if (process.env.WHITELISTED_MUTATIONS.startsWith("[")) {
WHITELISTED_MUTATIONS = JSON.parse(process.env.WHITELISTED_MUTATIONS);
} else {
// Otherwise treat as comma-separated list
WHITELISTED_MUTATIONS = process.env.WHITELISTED_MUTATIONS.split(",")
.map((q) => q.trim())
.filter(Boolean);
}
}
log(
"info",
`Loaded mutation whitelist with ${
(WHITELISTED_MUTATIONS && WHITELISTED_MUTATIONS.length) || 0
} mutations`,
{ whitelist: WHITELISTED_MUTATIONS }
);
} catch (error) {
log(
"error",
`Failed to parse WHITELISTED_MUTATIONS: ${
error instanceof Error ? error.message : String(error)
}`
);
// If parsing fails, don't use a whitelist
WHITELISTED_MUTATIONS = null;
}
}
// Debug logging to stderr
function log(level: string, message: string, data: LogData = {}): void {
const timestamp = new Date().toISOString();
console.error(JSON.stringify({ timestamp, level, message, ...data }));
}
// Global state
let schemaCache: GraphQLSchema | null = null;
let schemaFetchInProgress: boolean = false;
let schemaCacheExpiry: number | null = null;
// Map of truncated tool names to original field names
let toolNameMappings: Record<string, string> = {};
// Execute GraphQL query
async function executeQuery(args: QueryExecutionArgs): Promise<any> {
try {
const endpoint = args.endpoint || GRAPHQL_API_ENDPOINT;
if (!endpoint) {
throw new Error("No GraphQL endpoint specified");
}
const headers: Record<string, string> = { ...(args.headers || {}) };
if (GRAPHQL_API_KEY && !headers.Authorization) {
headers.Authorization = `Bearer ${GRAPHQL_API_KEY}`;
}
// Validate query
try {
parse(args.query);
} catch (error) {
throw new Error(
`Invalid GraphQL query: ${
error instanceof Error ? error.message : String(error)
}`
);
}
// Create client and execute query
const client = new GraphQLClient(endpoint, { headers });
const startTime = Date.now();
const result = await client.request(args.query, args.variables || {});
const duration = Date.now() - startTime;
log("info", `GraphQL query executed successfully in ${duration}ms`);
// Return the result directly, it will be formatted in the tool handler
return result;
} catch (error) {
log(
"error",
`Error executing GraphQL query: ${
error instanceof Error ? error.message : String(error)
}`
);
throw new Error(
`GraphQL query error: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
// Fetch schema and generate tools
async function fetchSchema(): Promise<GraphQLSchema | null> {
// Prevent concurrent schema fetches
if (schemaFetchInProgress) {
log("info", "Schema fetch already in progress, waiting for it to complete");
while (schemaFetchInProgress) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
return schemaCache;
}
// Check if we have a valid cached schema
const now = Date.now();
if (schemaCache && schemaCacheExpiry && now < schemaCacheExpiry) {
log("info", "Using cached schema");
return schemaCache;
}
try {
schemaFetchInProgress = true;
log("info", `Fetching GraphQL schema from ${GRAPHQL_API_ENDPOINT}`);
// Build headers
const headers: Record<string, string> = {};
if (GRAPHQL_API_KEY) {
headers.Authorization = `Bearer ${GRAPHQL_API_KEY}`;
}
// Create client and execute introspection query
const client = new GraphQLClient(GRAPHQL_API_ENDPOINT, { headers });
const startTime = Date.now();
const data = await client.request<IntrospectionQuery>(
getIntrospectionQuery()
);
const schema = buildClientSchema(data);
const duration = Date.now() - startTime;
log("info", `Schema fetched successfully in ${duration}ms`);
// Cache the schema and reset the name mappings
schemaCache = schema;
schemaCacheExpiry = now + CACHE_TTL;
toolNameMappings = {}; // Reset mappings as schema might have changed
return schema;
} catch (error) {
log(
"error",
`Error fetching schema: ${
error instanceof Error ? error.message : String(error)
}`
);
return null;
} finally {
schemaFetchInProgress = false;
}
}
// Get the actual type from a GraphQL type (removing List and NonNull wrappers)
function getNamedType(type: GraphQLType): GraphQLType {
if (isNonNullType(type)) {
return getNamedType(type.ofType);
}
if (isListType(type)) {
return getNamedType(type.ofType);
}
return type;
}
// Get tools based on schema - TRULY DYNAMIC TOOLS GENERATION
function getToolsFromSchema(schema: GraphQLSchema | null): MCPTool[] {
if (!schema) {
return []; // Return empty tools list if no schema available
}
const tools: MCPTool[] = [];
// Get the query type from the schema
const queryType = schema.getQueryType();
if (!queryType) {
log("warning", "Schema has no query type");
return tools;
}
// Get all available query fields
const fields = queryType.getFields();
// Process each query field as a potential tool
for (const [fieldName, field] of Object.entries(fields)) {
// Skip fields that aren't in the whitelist (if a whitelist is provided)
if (WHITELISTED_QUERIES && !WHITELISTED_QUERIES.includes(fieldName)) {
if (DEBUG) {
log("debug", `Skipping field ${fieldName} - not in whitelist`);
}
continue;
}
try {
// Analyze arguments for the field
const properties: Record<string, any> = {};
const required: string[] = [];
// Process arguments
field.args.forEach((arg) => {
// Get the actual GraphQL type (unwrap non-null and list types)
let argType = arg.type;
let isNonNull = false;
if (isNonNullType(argType)) {
isNonNull = true;
argType = argType.ofType;
}
let isList = false;
if (isListType(argType)) {
isList = true;
argType = argType.ofType;
// Unwrap non-null inside list if needed
if (isNonNullType(argType)) {
argType = argType.ofType;
}
}
const baseType = getNamedType(argType);
// Create property definition
if (isScalarType(baseType)) {
// For scalar types like Int, String, etc.
properties[arg.name] = {
type: getJsonSchemaType(baseType.name),
description:
arg.description || `${arg.name} parameter (${baseType.name})`,
};
} else if (isEnumType(baseType)) {
// For enum types, specify allowed values
const enumValues = baseType.getValues().map((val) => val.name);
properties[arg.name] = {
type: "string",
enum: enumValues,
description:
arg.description || `${arg.name} parameter (${baseType.name})`,
};
} else if (isInputObjectType(baseType)) {
// For input object types (complex types), describe it as object
properties[arg.name] = {
type: "object",
description: `${arg.name} - Input type: ${baseType.name}`,
};
// If we wanted to go deeper, we could recursively define the object structure:
// properties[arg.name].properties = getInputObjectProperties(baseType);
} else {
// Default catch-all
properties[arg.name] = {
type: "string",
description: arg.description || `${arg.name} parameter`,
};
}
// If arg is non-null, add to required list
if (isNonNull) {
required.push(arg.name);
}
});
// Create a tool for this field - Truncate name to 64 characters if needed
const truncatedName =
fieldName.length > 64 ? fieldName.substring(0, 64) : fieldName;
// Store mapping from truncated to original name if truncation occurred
if (truncatedName !== fieldName) {
toolNameMappings[truncatedName] = fieldName;
}
const tool: MCPTool = {
name: truncatedName,
description: field.description || `GraphQL ${fieldName} query`,
inputSchema: {
type: "object",
properties: properties,
required: required,
},
};
tools.push(tool);
} catch (error) {
log(
"error",
`Error processing field ${fieldName}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
return tools;
}
// Get tools based on mutation schema
function getToolsFromMutationType(schema: GraphQLSchema | null): MCPTool[] {
if (!schema) {
return []; // Return empty tools list if no schema available
}
const tools: MCPTool[] = [];
// Get the mutation type from the schema
const mutationType = schema.getMutationType();
if (!mutationType) {
log("info", "Schema has no mutation type");
return tools;
}
// Get all available mutation fields
const fields = mutationType.getFields();
// Process each mutation field as a potential tool
for (const [fieldName, field] of Object.entries(fields)) {
// Skip fields that aren't in the whitelist (if a whitelist is provided)
if (WHITELISTED_MUTATIONS && !WHITELISTED_MUTATIONS.includes(fieldName)) {
if (DEBUG) {
log("debug", `Skipping mutation field ${fieldName} - not in whitelist`);
}
continue;
}
try {
// Analyze arguments for the field
const properties: Record<string, any> = {};
const required: string[] = [];
// Process arguments (same logic as query arguments)
field.args.forEach((arg) => {
// Get the actual GraphQL type (unwrap non-null and list types)
let argType = arg.type;
let isNonNull = false;
if (isNonNullType(argType)) {
isNonNull = true;
argType = argType.ofType;
}
let isList = false;
if (isListType(argType)) {
isList = true;
argType = argType.ofType;
// Unwrap non-null inside list if needed
if (isNonNullType(argType)) {
argType = argType.ofType;
}
}
const baseType = getNamedType(argType);
// Create property definition
if (isScalarType(baseType)) {
// For scalar types like Int, String, etc.
properties[arg.name] = {
type: getJsonSchemaType(baseType.name),
description:
arg.description || `${arg.name} parameter (${baseType.name})`,
};
} else if (isEnumType(baseType)) {
// For enum types, specify allowed values
const enumValues = baseType.getValues().map((val) => val.name);
properties[arg.name] = {
type: "string",
enum: enumValues,
description:
arg.description || `${arg.name} parameter (${baseType.name})`,
};
} else if (isInputObjectType(baseType)) {
// For input object types (complex types), describe it as object
properties[arg.name] = {
type: "object",
description: `${arg.name} - Input type: ${baseType.name}`,
};
} else {
// Default catch-all
properties[arg.name] = {
type: "string",
description: arg.description || `${arg.name} parameter`,
};
}
// If arg is non-null, add to required list
if (isNonNull) {
required.push(arg.name);
}
});
// Create a tool for this mutation field - Truncate name to 64 characters if needed
let fullName = `mutation_${fieldName}`;
let truncatedName = fullName;
if (fullName.length > 64) {
// Truncate the fieldName part to make the full name fit in 64 chars
// Keep the mutation_ prefix for clarity
const prefixLength = "mutation_".length;
const maxFieldNameLength = 64 - prefixLength;
truncatedName = `mutation_${fieldName.substring(
0,
maxFieldNameLength
)}`;
// Store mapping from truncated to original full name
toolNameMappings[truncatedName] = fullName;
}
const tool: MCPTool = {
name: truncatedName,
description: field.description || `GraphQL ${fieldName} mutation`,
inputSchema: {
type: "object",
properties: properties,
required: required,
},
};
tools.push(tool);
} catch (error) {
log(
"error",
`Error processing mutation field ${fieldName}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
return tools;
}
// Helper function to convert GraphQL scalar types to JSON Schema types
function getJsonSchemaType(graphqlType: string): string {
const typeMap: Record<string, string> = {
Int: "integer",
Float: "number",
String: "string",
Boolean: "boolean",
ID: "string",
};
return typeMap[graphqlType] || "string";
}
// Analyze object type fields to efficiently build query
function analyzeFields(
type: GraphQLObjectType,
schema: GraphQLSchema,
visitedTypes: Set<string> = new Set(),
depth: number = 0
): FieldSelection[] {
// Prevent infinite recursion and limit depth
if (visitedTypes.has(type.name) || depth > 2) {
return [];
}
// For non-object types, just return empty array
if (!isObjectType(type)) {
return [];
}
visitedTypes.add(type.name);
const fields = type.getFields();
const result: FieldSelection[] = [];
// Add fields to query (being careful of nested objects to avoid N+1 problems)
for (const [fieldName, field] of Object.entries(fields)) {
// Skip fields that need arguments - they might cause issues
if (field.args.length > 0) {
continue;
}
const fieldType = getNamedType(field.type);
if (isScalarType(fieldType) || isEnumType(fieldType)) {
// Include all scalar and enum fields directly
result.push(fieldName);
} else if (isObjectType(fieldType) && depth < 2) {
// For object types, recurse one level to include key identifying fields
// This is where we're careful about N+1 issues - limited depth
const nestedFields = analyzeFields(
fieldType,
schema,
visitedTypes,
depth + 1
);
if (nestedFields.length > 0) {
result.push({
name: fieldName,
fields: nestedFields.slice(0, 3), // Limit to first few fields to avoid overfetching
});
}
}
}
return result;
}
// Build selection set string for GraphQL query
function buildSelectionSet(
fields: FieldSelection[],
indent: string = " "
): string {
if (!fields || fields.length === 0) return "";
let result = "";
for (const field of fields) {
if (typeof field === "string") {
// It's a scalar field
result += `${indent}${field}\n`;
} else if (typeof field === "object" && field.name && field.fields) {
// It's an object field with nested fields
result += `${indent}${field.name} {\n${buildSelectionSet(
field.fields,
indent + " "
)}\n${indent}}\n`;
}
}
return result;
}
// Process and validate input arguments based on schema
function processArguments(
args: Record<string, any> | undefined,
fieldArgs: readonly GraphQLArgument[],
schema: GraphQLSchema
): Record<string, any> {
if (!args || !fieldArgs) return {};
const processedArgs: Record<string, any> = {};
// Process each argument
for (const [argName, argValue] of Object.entries(args)) {
// Find the argument definition in the schema
const argDef = fieldArgs.find((a) => a.name === argName);
if (!argDef) continue;
const baseType = getNamedType(argDef.type);
// Handle based on the type
if (isInputObjectType(baseType)) {
// For complex input types
if (argValue === "") {
// If it's an empty string, convert to null or empty object
// depending on if it's required
if (isNonNullType(argDef.type)) {
processedArgs[argName] = {}; // Empty object for required inputs
} else {
processedArgs[argName] = null; // null for optional inputs
}
} else if (
typeof argValue === "string" &&
(argValue.startsWith("{") || argValue === "")
) {
// Try to parse as JSON if it looks like an object
try {
processedArgs[argName] = JSON.parse(argValue);
} catch (e) {
// If it can't be parsed, use null or empty object
processedArgs[argName] = isNonNullType(argDef.type) ? {} : null;
}
} else {
// Use as is if it's already an object
processedArgs[argName] = argValue;
}
} else if (isEnumType(baseType)) {
// For enum types, ensure the value is a string
processedArgs[argName] = String(argValue);
} else if (isScalarType(baseType)) {
// Handle scalar types
if (baseType.name === "Int") {
processedArgs[argName] = parseInt(argValue, 10);
} else if (baseType.name === "Float") {
processedArgs[argName] = parseFloat(argValue);
} else if (baseType.name === "Boolean") {
processedArgs[argName] = Boolean(argValue);
} else {
processedArgs[argName] = argValue;
}
} else {
// Default: use as-is
processedArgs[argName] = argValue;
}
// Skip null or undefined values for non-required fields
if (
(processedArgs[argName] === null ||
processedArgs[argName] === undefined) &&
!isNonNullType(argDef.type)
) {
delete processedArgs[argName];
}
}
return processedArgs;
}
// Handle tool execution
async function executeGraphQLTool(
name: string,
args: Record<string, any> | undefined
): Promise<any> {
try {
// If this name is in our mapping, use the original field name
const actualFieldName = toolNameMappings[name] || name;
// Check if the tool is in the whitelist (if whitelist is enabled)
if (WHITELISTED_QUERIES && !WHITELISTED_QUERIES.includes(actualFieldName)) {
throw new Error(`Tool '${actualFieldName}' is not in the whitelist`);
}
// Get the schema
const schema = await fetchSchema();
if (!schema) {
throw new Error("Schema not available");
}
// Get the query type
const queryType = schema.getQueryType();
if (!queryType) {
throw new Error("Schema has no query type");
}
// Get the field for this tool using the resolved field name
const fields = queryType.getFields();
const field = fields[actualFieldName];
if (!field) {
throw new Error(`Unknown field: ${actualFieldName}`);
}
try {
// Process input arguments
const processedArgs = processArguments(args, field.args, schema);
// Get return type and analyze it
const returnType = getNamedType(field.type);
// First determine which arguments are actually being used
const usedArgNames: string[] = [];
field.args.forEach((arg) => {
if (processedArgs && processedArgs[arg.name] !== undefined) {
usedArgNames.push(arg.name);
}
});
// Build variables definition - only for arguments that are actually used
const varDefs = field.args
.filter((arg) => usedArgNames.includes(arg.name))
.map((arg) => {
// Need to preserve non-null and list wrappers in variable definitions
const typeStr = arg.type.toString();
return `$${arg.name}: ${typeStr}`;
})
.filter(Boolean)
.join(", ");
// Build field arguments - only for arguments that are actually used
const fieldArgs = usedArgNames
.map((argName) => {
return `${argName}: $${argName}`;
})
.join(", ");
// Build selection set based on return type
let selectionSet = "";
if (isObjectType(returnType)) {
// For objects or lists of objects, analyze fields
const fields = analyzeFields(returnType, schema);
selectionSet = buildSelectionSet(fields);
} else if (isListType(returnType)) {
// For lists, unwrap and analyze the inner type
const innerType = getNamedType(returnType.ofType);
if (isObjectType(innerType)) {
const fields = analyzeFields(innerType as GraphQLObjectType, schema);
selectionSet = buildSelectionSet(fields);
}
}
// Build the final query
// Only include variable definitions if fieldArgs is used
const shouldIncludeVarDefs = fieldArgs && fieldArgs.length > 0;
const query = `
query ${name}Query${shouldIncludeVarDefs ? `(${varDefs})` : ""} {
${name}${fieldArgs && fieldArgs.length > 0 ? `(${fieldArgs})` : ""} ${
selectionSet ? `{\n${selectionSet} }` : ""
}
}
`;
log("debug", `Generated query for ${name}:`, {
query,
variables: processedArgs,
});
// Execute the query
return await executeQuery({ query, variables: processedArgs });
} catch (error) {
throw new Error(
`Error executing query for ${name}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
} catch (error) {
log(
"error",
`Error executing tool ${name}: ${
error instanceof Error ? error.message : String(error)
}`
);
throw error;
}
}
// Handle mutation execution
async function executeGraphQLMutation(
name: string,
args: Record<string, any> | undefined
): Promise<any> {
try {
// If this name is in our mapping, use the original field name
const fullName = toolNameMappings[name] || name;
// Extract the actual mutation name (remove 'mutation_' prefix)
const mutationName = fullName.replace(/^mutation_/, "");
// Check if the mutation is in the whitelist (if whitelist is enabled)
if (
WHITELISTED_MUTATIONS &&
!WHITELISTED_MUTATIONS.includes(mutationName)
) {
throw new Error(`Mutation '${mutationName}' is not in the whitelist`);
}
// Get the schema
const schema = await fetchSchema();
if (!schema) {
throw new Error("Schema not available");
}
// Get the mutation type
const mutationType = schema.getMutationType();
if (!mutationType) {
throw new Error("Schema has no mutation type");
}
// Get the field for this mutation
const fields = mutationType.getFields();
const field = fields[mutationName];
if (!field) {
throw new Error(`Unknown mutation: ${mutationName}`);
}
try {
// Process input arguments
const processedArgs = processArguments(args, field.args, schema);
// Get return type and analyze it
const returnType = getNamedType(field.type);
// First determine which arguments are actually being used
const usedArgNames: string[] = [];
field.args.forEach((arg) => {
if (processedArgs && processedArgs[arg.name] !== undefined) {
usedArgNames.push(arg.name);
}
});
// Build variables definition - only for arguments that are actually used
const varDefs = field.args
.filter((arg) => usedArgNames.includes(arg.name))
.map((arg) => {
// Need to preserve non-null and list wrappers in variable definitions
const typeStr = arg.type.toString();
return `$${arg.name}: ${typeStr}`;
})
.filter(Boolean)
.join(", ");
// Build field arguments - only for arguments that are actually used
const fieldArgs = usedArgNames
.map((argName) => {
return `${argName}: $${argName}`;
})
.join(", ");
// Build selection set based on return type
let selectionSet = "";
if (isObjectType(returnType)) {
// For objects or lists of objects, analyze fields
const fields = analyzeFields(returnType, schema);
selectionSet = buildSelectionSet(fields);
} else if (isListType(returnType)) {
// For lists, unwrap and analyze the inner type
const innerType = getNamedType(returnType.ofType);
if (isObjectType(innerType)) {
const fields = analyzeFields(innerType as GraphQLObjectType, schema);
selectionSet = buildSelectionSet(fields);
}
}
// Build the final mutation query
const shouldIncludeVarDefs = fieldArgs && fieldArgs.length > 0;
const mutation = `
mutation ${mutationName}Mutation${
shouldIncludeVarDefs ? `(${varDefs})` : ""
} {
${mutationName}${
fieldArgs && fieldArgs.length > 0 ? `(${fieldArgs})` : ""
} ${selectionSet ? `{\n${selectionSet} }` : ""}
}
`;
log("debug", `Generated mutation for ${mutationName}:`, {
mutation,
variables: processedArgs,
});
// Execute the mutation
return await executeQuery({ query: mutation, variables: processedArgs });
} catch (error) {
throw new Error(
`Error executing mutation for ${mutationName}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
} catch (error) {
log(
"error",
`Error executing mutation ${name}: ${
error instanceof Error ? error.message : String(error)
}`
);
throw error;
}
}
// Provide resources list (empty but handled to be more compatible)
function getResourcesList(): { resources: any[] } {
return { resources: [] };
}
// Provide prompts list (empty but handled to be more compatible)
function getPromptsList(): { prompts: any[] } {
return { prompts: [] };
}
async function main(): Promise<void> {
try {
log("info", "GraphQL MCP Server starting...");
log("info", `GraphQL API Endpoint: ${GRAPHQL_API_ENDPOINT}`);
log(
"info",
`API Key: ${GRAPHQL_API_KEY ? "Configured" : "Not configured"}`
);
log(
"info",
`Query Whitelist: ${
WHITELISTED_QUERIES
? `Enabled (${WHITELISTED_QUERIES.length} queries)`
: "Disabled (all queries allowed)"
}`
);
log(
"info",
`Mutation Whitelist: ${
WHITELISTED_MUTATIONS
? `Enabled (${WHITELISTED_MUTATIONS.length} mutations)`
: "Disabled (all mutations allowed)"
}`
);
// Set up readline interface for stdin/stdout
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false,
});
// Handle incoming JSON-RPC messages
rl.on("line", async (line: string) => {
try {
if (DEBUG) {
log("debug", `Received message: ${line.substring(0, 100)}...`);
}
const request: JSONRPCRequest = JSON.parse(line);
const { method, id } = request;
// Handle initialize
if (method === "initialize") {
log("info", "Handling initialize request");
const response: JSONRPCResponse = {
jsonrpc: "2.0",
id,
result: {
protocolVersion: "2024-11-05",
capabilities: {
tools: {},
resources: {},
prompts: {},
},
serverInfo: {
name: "graphql-mcp-server",
version: "1.0.0",
},
},
};
console.log(JSON.stringify(response));
return;
}
// Handle initialized notification
if (method === "notifications/initialized") {
// No response needed for notifications
return;
}
// Handle tools/list
if (method === "tools/list") {
// Get schema (will use cache if available)
const schema = await fetchSchema();
const queryTools = getToolsFromSchema(schema);
// Only include mutations if they are enabled
const mutationTools = ENABLE_MUTATIONS ? getToolsFromMutationType(schema) : [];
const allTools = [...queryTools, ...mutationTools];
log(
"info",
`Returning ${allTools.length} tools (${queryTools.length} queries, ${mutationTools.length} mutations${!ENABLE_MUTATIONS ? ' - mutations disabled' : ''})`
);
const response: JSONRPCResponse = {
jsonrpc: "2.0",
id,
result: {
tools: allTools,
},
};
console.log(JSON.stringify(response));
return;
}
// Handle tools/call
if (method === "tools/call" || method === "tool.call") {
const { name, arguments: args } = request.params;
log("info", `Tool call: ${name}`, { args });
// Determine if this is a mutation or a query
const isMutation = name.startsWith("mutation_");
// Check if mutations are enabled
if (isMutation && !ENABLE_MUTATIONS) {
log("error", `Mutation ${name} called but mutations are disabled`);
const response: JSONRPCResponse = {
jsonrpc: "2.0",
id,
error: {
code: -32000,
message: "Mutations are disabled. Set ENABLE_MUTATIONS=true to enable them.",
},
};
console.log(JSON.stringify(response));
return;
}
// Get result from GraphQL tool execution
try {
let result;
if (isMutation) {
result = await executeGraphQLMutation(name, args);
} else {
result = await executeGraphQLTool(name, args);
}
// Log the result structure
log("debug", `Result structure for ${name}:`, {
resultStructure: JSON.stringify(result),
});
// Handle undefined and null values to prevent Zod validation errors
const sanitizeValue = (obj: any): any => {
if (obj === undefined || obj === null) {
return null;
}
if (typeof obj === "object" && obj !== null) {
if (Array.isArray(obj)) {
return obj.map((item) => sanitizeValue(item));
} else {
const newObj: Record<string, any> = {};
for (const [key, value] of Object.entries(obj)) {
newObj[key] = sanitizeValue(value);
}
return newObj;
}
}
return obj;
};
// Sanitize the result to avoid Zod validation errors
const sanitizedResult = JSON.stringify(
sanitizeValue(result),
null,
2
);
const response: JSONRPCResponse = {
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: sanitizedResult || "{}" }],
},
};
console.log(JSON.stringify(response));
} catch (error) {
log(
"error",
`Error in tool ${name}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Format error response
const response: JSONRPCResponse = {
jsonrpc: "2.0",
id,
error: {
code: -32000,
message: error instanceof Error ? error.message : String(error),
},
};
console.log(JSON.stringify(response));
}
return;
}
// Handle resources/list
if (method === "resources/list") {
const response: JSONRPCResponse = {
jsonrpc: "2.0",
id,
result: getResourcesList(),
};
console.log(JSON.stringify(response));
return;
}
// Handle prompts/list
if (method === "prompts/list") {
const response: JSONRPCResponse = {
jsonrpc: "2.0",
id,
result: getPromptsList(),
};
console.log(JSON.stringify(response));
return;
}
// Method not found
log("warning", `Method not found: ${method}`);
const response: JSONRPCResponse = {
jsonrpc: "2.0",
id,
error: {
code: -32601,
message: `Method '${method}' not found`,
},
};
console.log(JSON.stringify(response));
} catch (error) {
log(
"error",
`Error processing message: ${
error instanceof Error ? error.message : String(error)
}`,
{
stack: error instanceof Error ? error.stack : undefined,
}
);
// Try to get the ID from the original message
let id = null;
try {
id = JSON.parse(line).id;
} catch {
// Ignore parse errors
}
// Return error response
const response: JSONRPCResponse = {
jsonrpc: "2.0",
id,
error: {
code: -32000,
message: `Error processing request: ${
error instanceof Error ? error.message : String(error)
}`,
},
};
console.log(JSON.stringify(response));
}
});
// Handle process termination
process.on("uncaughtException", (error: Error) => {
log("error", `Uncaught exception: ${error.message}`, {
stack: error.stack,
});
});
process.on("unhandledRejection", (reason: any) => {
log(
"error",
`Unhandled rejection: ${
reason instanceof Error ? reason.message : String(reason)
}`
);
});
// Initialize schema in the background
fetchSchema().catch((error) => {
log(
"error",
`Initial schema fetch failed: ${
error instanceof Error ? error.message : String(error)
}`
);
});
log("info", "GraphQL MCP Server started and ready");
} catch (error) {
log("error", "Fatal error during initialization:", {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
process.exit(1);
}
}
// Start the server
main().catch((error) => {
console.error(
`Fatal error: ${error instanceof Error ? error.message : String(error)}`
);
process.exit(1);
});