Skip to main content
Glama

Advanced Hasura GraphQL MCP Server

by husamabusafa
index.ts22.8 kB
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z, ZodRawShape } from "zod"; import { GraphQLClient, gql, ClientError } from 'graphql-request'; import { getIntrospectionQuery, IntrospectionQuery, IntrospectionObjectType, IntrospectionSchema, IntrospectionField, IntrospectionInputValue, IntrospectionInterfaceType, IntrospectionInputObjectType, IntrospectionEnumType, IntrospectionUnionType } from 'graphql'; const SERVER_NAME = "mcp-servers/hasura-advanced"; const SERVER_VERSION = "1.1.0"; const SCHEMA_RESOURCE_URI = "hasura:/schema"; const SCHEMA_RESOURCE_NAME = "Hasura GraphQL Schema (via Introspection)"; const SCHEMA_MIME_TYPE = "application/json"; const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION, capabilities: { resources: {}, tools: {}, }, }); const args = process.argv.slice(2); if (args.length < 1) { console.error(`Usage: node ${process.argv[1]} <HASURA_GRAPHQL_ENDPOINT> [ADMIN_SECRET]`); process.exit(1); } const HASURA_ENDPOINT = args[0]; const ADMIN_SECRET = args[1]; console.log(`[INFO] Targeting Hasura Endpoint: ${HASURA_ENDPOINT}`); if (ADMIN_SECRET) { console.log("[INFO] Using Admin Secret."); } else { console.warn("[WARN] No Admin Secret provided. Ensure Hasura permissions are configured for the default role."); } const headers: Record<string, string> = {}; if (ADMIN_SECRET) { headers['x-hasura-admin-secret'] = ADMIN_SECRET; } const gqlClient = new GraphQLClient(HASURA_ENDPOINT, { headers }); async function makeGqlRequest< T = any, V extends Record<string, any> = Record<string, any> >( query: string, variables?: V, requestHeaders?: Record<string, string> ): Promise<T> { try { const combinedHeaders = { ...headers, ...requestHeaders }; return await gqlClient.request<T>(query, variables, combinedHeaders); } catch (error) { if (error instanceof ClientError) { const gqlErrors = error.response?.errors?.map(e => e.message).join(', ') || 'Unknown GraphQL error'; console.error(`[ERROR] GraphQL Request Failed: ${gqlErrors}`, error.response); throw new Error(`GraphQL operation failed: ${gqlErrors}`); } console.error("[ERROR] Unexpected error during GraphQL request:", error); throw error; } } let introspectionSchema: IntrospectionSchema | null = null; async function getIntrospectionSchema(): Promise<IntrospectionSchema> { if (introspectionSchema) { return introspectionSchema; } console.log("[INFO] Fetching GraphQL schema via introspection..."); const introspectionQuery = getIntrospectionQuery(); try { const result = await makeGqlRequest<IntrospectionQuery>(introspectionQuery); if (!result.__schema) { throw new Error("Introspection query did not return a __schema object."); } introspectionSchema = result.__schema; console.log("[INFO] Introspection successful, schema cached."); return introspectionSchema; } catch (error) { console.error("[ERROR] Failed to fetch or cache introspection schema:", error); introspectionSchema = null; throw new Error(`Failed to get GraphQL schema: ${error instanceof Error ? error.message : String(error)}`); } } server.resource( SCHEMA_RESOURCE_NAME, SCHEMA_RESOURCE_URI, { mimeType: SCHEMA_MIME_TYPE }, async () => { console.log(`[INFO] Handling read request for resource: ${SCHEMA_RESOURCE_URI}`); try { const schema = await getIntrospectionSchema(); return { contents: [ { uri: SCHEMA_RESOURCE_URI, text: JSON.stringify(schema, null, 2), mimeType: SCHEMA_MIME_TYPE } ] }; } catch (error) { console.error(`[ERROR] Failed to provide schema resource: ${error}`); throw new Error(`Failed to retrieve GraphQL schema: ${error instanceof Error ? error.message : String(error)}`); } } ); server.tool( "run_graphql_query", "Executes a read-only GraphQL query against the Hasura endpoint...", { query: z.string().describe("The GraphQL query string (must be a read-only operation)."), variables: z.record(z.unknown()).optional().describe("Optional. An object containing variables..."), }, async ({ query, variables }) => { console.log(`[INFO] Executing tool 'run_graphql_query'`); if (query.trim().toLowerCase().startsWith('mutation')) { throw new Error("This tool only supports read-only queries..."); } try { const result = await makeGqlRequest(query, variables); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } catch (error: any) { console.error(`[ERROR] Tool 'run_graphql_query' failed: ${error.message}`); throw error; } } ); server.tool( "run_graphql_mutation", "Executes a GraphQL mutation to insert, update, or delete data...", { mutation: z.string().describe("The GraphQL mutation string."), variables: z.record(z.unknown()).optional().describe("Optional. An object containing variables..."), }, async ({ mutation, variables }) => { console.log(`[INFO] Executing tool 'run_graphql_mutation'`); if (!mutation.trim().toLowerCase().startsWith('mutation')) { throw new Error("The provided string does not appear to be a mutation..."); } try { const result = await makeGqlRequest(mutation, variables); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } catch (error: any) { console.error(`[ERROR] Tool 'run_graphql_mutation' failed: ${error.message}`); throw error; } } ); server.tool( "list_tables", "Lists available data tables (or collections) managed by Hasura, organized by schema with descriptions", { schemaName: z.string().optional().describe("Optional. The database schema name to filter results. If omitted, returns tables from all schemas.") }, async ({ schemaName }) => { console.log(`[INFO] Executing tool 'list_tables' for schema: ${schemaName || 'ALL'}`); try { const schema = await getIntrospectionSchema(); const query = gql` query GetTablesWithDescriptions { __type(name: "query_root") { fields { name description type { name kind } } } } `; const result = await makeGqlRequest(query); const tablesData: Record<string, Array<{name: string, description: string | null}>> = {}; if (result.__type && result.__type.fields) { const fieldEntries = result.__type.fields; for (const field of fieldEntries) { if (field.name.includes('_aggregate') || field.name.includes('_by_pk') || field.name.includes('_stream') || field.name.includes('_mutation') || field.name.startsWith('__')) { continue; } let currentSchema = 'public'; if (field.description && field.description.includes('schema:')) { const schemaMatch = field.description.match(/schema:\s*([^\s,]+)/i); if (schemaMatch && schemaMatch[1]) { currentSchema = schemaMatch[1]; } } if (schemaName && currentSchema !== schemaName) { continue; } if (!tablesData[currentSchema]) { tablesData[currentSchema] = []; } tablesData[currentSchema].push({ name: field.name, description: field.description }); } } const formattedOutput = Object.entries(tablesData) .map(([schema, tables]) => ({ schema, tables: tables.sort((a, b) => a.name.localeCompare(b.name)) })) .sort((a, b) => a.schema.localeCompare(b.schema)); return { content: [{ type: "text", text: JSON.stringify(formattedOutput, null, 2) }] }; } catch (error: any) { console.error(`[ERROR] Tool 'list_tables' failed: ${error.message}`); throw error; } } ); server.tool( "list_root_fields", "Lists the available top-level query, mutation, or subscription fields...", { fieldType: z.enum(["QUERY", "MUTATION", "SUBSCRIPTION"]).optional().describe("Optional. Filter by 'QUERY'...") }, async ({ fieldType }) => { console.log(`[INFO] Executing tool 'list_root_fields', filtering by: ${fieldType || 'ALL'}`); try { const schema = await getIntrospectionSchema(); let fields: IntrospectionField[] = []; if ((!fieldType || fieldType === "QUERY") && schema.queryType) { const queryRoot = schema.types.find(t => t.name === schema.queryType?.name) as IntrospectionObjectType | undefined; fields = fields.concat(queryRoot?.fields || []); } if ((!fieldType || fieldType === "MUTATION") && schema.mutationType) { const mutationRoot = schema.types.find(t => t.name === schema.mutationType?.name) as IntrospectionObjectType | undefined; fields = fields.concat(mutationRoot?.fields || []); } if ((!fieldType || fieldType === "SUBSCRIPTION") && schema.subscriptionType) { const subscriptionRoot = schema.types.find(t => t.name === schema.subscriptionType?.name) as IntrospectionObjectType | undefined; fields = fields.concat(subscriptionRoot?.fields || []); } const fieldInfo = fields.map(f => ({ name: f.name, description: f.description || "No description.", })).sort((a, b) => a.name.localeCompare(b.name)); return { content: [{ type: "text", text: JSON.stringify(fieldInfo, null, 2) }] }; } catch (error: any) { console.error(`[ERROR] Tool 'list_root_fields' failed: ${error.message}`); throw error; } } ); server.tool( "describe_graphql_type", "Provides details about a specific GraphQL type (Object, Input, Scalar, Enum, Interface, Union)...", { typeName: z.string().describe("The exact, case-sensitive name of the GraphQL type..."), }, async ({ typeName }) => { console.log(`[INFO] Executing tool 'describe_graphql_type' for type: ${typeName}`); try { const schema = await getIntrospectionSchema(); const typeInfo = schema.types.find(t => t.name === typeName); if (!typeInfo) { throw new Error(`Type '${typeName}' not found in the schema.`); } const formattedInfo = { kind: typeInfo.kind, name: typeInfo.name, description: typeInfo.description || null, ...(typeInfo.kind === 'OBJECT' || typeInfo.kind === 'INTERFACE' ? { fields: (typeInfo as IntrospectionObjectType | IntrospectionInterfaceType).fields?.map((f: IntrospectionField) => ({ name: f.name, description: f.description || null, type: JSON.stringify(f.type), args: f.args?.map((a: IntrospectionInputValue) => ({ name: a.name, type: JSON.stringify(a.type) })) || [] })) || [] } : {}), ...(typeInfo.kind === 'INPUT_OBJECT' ? { inputFields: (typeInfo as IntrospectionInputObjectType).inputFields?.map((f: IntrospectionInputValue) => ({ name: f.name, description: f.description || null, type: JSON.stringify(f.type), })) || [] } : {}), ...(typeInfo.kind === 'ENUM' ? { enumValues: (typeInfo as IntrospectionEnumType).enumValues?.map(ev => ({ name: ev.name, description: ev.description || null })) || [] } : {}), ...(typeInfo.kind === 'UNION' || typeInfo.kind === 'INTERFACE' ? { possibleTypes: (typeInfo as IntrospectionUnionType | IntrospectionInterfaceType).possibleTypes?.map(pt => pt.name) || [] } : {}), }; return { content: [{ type: "text", text: JSON.stringify(formattedInfo, null, 2) }] }; } catch (error: any) { console.error(`[ERROR] Tool 'describe_graphql_type' failed: ${error.message}`); throw error; } } ); server.tool( "preview_table_data", "Fetch esa limited sample of rows (default 5) from a specified table...", { tableName: z.string().describe("The exact name of the table..."), limit: z.number().int().positive().optional().default(5).describe("Optional. Maximum number of rows..."), }, async ({ tableName, limit }) => { console.log(`[INFO] Executing tool 'preview_table_data' for table: ${tableName}, limit: ${limit}`); try { const schema = await getIntrospectionSchema(); const tableType = schema.types.find(t => t.name === tableName && t.kind === 'OBJECT') as IntrospectionObjectType | undefined; if (!tableType) { throw new Error(`Table (Object type) '${tableName}' not found in schema.`); } const scalarFields = tableType.fields ?.filter(f => { let currentType = f.type; while (currentType.kind === 'NON_NULL' || currentType.kind === 'LIST') currentType = currentType.ofType; return currentType.kind === 'SCALAR' || currentType.kind === 'ENUM'; }) .map(f => f.name) || []; if (scalarFields.length === 0) { console.warn(`[WARN] No scalar fields found for table ${tableName}...`); scalarFields.push('__typename'); } const fieldsString = scalarFields.join('\n '); const query = gql` query PreviewData($limit: Int!) { ${tableName}(limit: $limit) { ${fieldsString} } }`; const variables = { limit }; const result = await makeGqlRequest(query, variables); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } catch (error: any) { console.error(`[ERROR] Tool 'preview_table_data' failed: ${error.message}`); throw error; } } ); server.tool( "aggregate_data", "Performs a simple aggregation (count, sum, avg, min, max)...", { tableName: z.string().describe("The exact name of the table..."), aggregateFunction: z.enum(["count", "sum", "avg", "min", "max"]).describe("The aggregation function..."), field: z.string().optional().describe("Required for 'sum', 'avg', 'min', 'max'..."), filter: z.record(z.unknown()).optional().describe("Optional. A Hasura GraphQL 'where' filter object..."), }, async ({ tableName, aggregateFunction, field, filter }) => { console.log(`[INFO] Executing tool 'aggregate_data': ${aggregateFunction} on ${tableName}...`); if (aggregateFunction !== 'count' && !field) { throw new Error(`The 'field' parameter is required for '${aggregateFunction}' aggregation.`); } if (aggregateFunction === 'count' && field) { console.warn(`[WARN] 'field' parameter is ignored for 'count' aggregation.`); } const aggregateTableName = `${tableName}_aggregate`; let aggregateSelection = ''; if (aggregateFunction === 'count') { aggregateSelection = `{ count }`; } else if (field) { aggregateSelection = `{ ${aggregateFunction} { ${field} } }`; } else { throw new Error(`'field' parameter is missing for '${aggregateFunction}' aggregation.`); } const boolExpTypeName = `${tableName}_bool_exp`; const filterVariableDefinition = filter ? `($filter: ${boolExpTypeName}!)` : ""; const whereClause = filter ? `where: $filter` : ""; const query = gql` query AggregateData ${filterVariableDefinition} { ${aggregateTableName}(${whereClause}) { aggregate ${aggregateSelection} } } `; const variables = filter ? { filter } : {}; try { const rawResult = await makeGqlRequest(query, variables); let finalResult = null; if (rawResult && rawResult[aggregateTableName] && rawResult[aggregateTableName].aggregate) { finalResult = rawResult[aggregateTableName].aggregate; } else { console.warn('[WARN] Unexpected result structure from aggregation query:', rawResult); finalResult = rawResult; } return { content: [{ type: "text", text: JSON.stringify(finalResult, null, 2) }] }; } catch (error: any) { if (error instanceof ClientError && error.response?.errors) { const gqlErrors = error.response.errors.map(e => e.message).join(', '); console.error(`[ERROR] Tool 'aggregate_data' failed: ${gqlErrors}`, error.response); throw new Error(`GraphQL aggregation failed: ${gqlErrors}. Check table/field names and filter syntax.`); } console.error(`[ERROR] Tool 'aggregate_data' failed: ${error.message}`); throw error; } } ); server.tool( "health_check", "Checks if the configured Hasura GraphQL endpoint is reachable...", { healthEndpointUrl: z.string().url().optional().describe("Optional. A specific HTTP health check URL...") }, async ({ healthEndpointUrl }) => { console.log(`[INFO] Executing tool 'health_check'...`); try { let resultText = ""; if (healthEndpointUrl) { console.log(`[DEBUG] Performing HTTP GET to: ${healthEndpointUrl}`); const response = await fetch(healthEndpointUrl, { method: 'GET' }); resultText = `Health endpoint ${healthEndpointUrl} status: ${response.status} ${response.statusText}`; if (!response.ok) throw new Error(resultText); } else { console.log(`[DEBUG] Performing GraphQL query { __typename } to: ${HASURA_ENDPOINT}`); const query = gql`query HealthCheck { __typename }`; const result = await makeGqlRequest(query); resultText = `GraphQL endpoint ${HASURA_ENDPOINT} is responsive. Result: ${JSON.stringify(result)}`; } return { content: [{ type: "text", text: `Health check successful. ${resultText}` }] }; } catch (error: any) { console.error(`[ERROR] Tool 'health_check' failed: ${error.message}`); return { content: [{ type: "text", text: `Health check failed: ${error.message}` }], isError: false }; } } ); server.tool( "describe_table", "Shows the structure of a table including all columns with their types and descriptions", { tableName: z.string().describe("The exact name of the table to describe"), schemaName: z.string().optional().default('public').describe("Optional. The database schema name, defaults to 'public'") }, async ({ tableName, schemaName }) => { console.log(`[INFO] Executing tool 'describe_table' for table: ${tableName} in schema: ${schemaName}`); try { const schema = await getIntrospectionSchema(); const tableTypeQuery = gql` query GetTableType($typeName: String!) { __type(name: $typeName) { name kind description fields { name description type { kind name ofType { kind name ofType { kind name ofType { kind name } } } } args { name description type { kind name ofType { kind name } } } } } } `; const tableTypeResult = await makeGqlRequest(tableTypeQuery, { typeName: tableName }); if (!tableTypeResult.__type) { console.log(`[INFO] No direct match for table type: ${tableName}, trying case variations`); const pascalCaseName = tableName.charAt(0).toUpperCase() + tableName.slice(1); const alternativeResult = await makeGqlRequest(tableTypeQuery, { typeName: pascalCaseName }); if (!alternativeResult.__type) { throw new Error(`Table '${tableName}' not found in schema. Check the table name and schema.`); } tableTypeResult.__type = alternativeResult.__type; } const columnsInfo = tableTypeResult.__type.fields.map((field: any) => { let typeInfo = field.type; let typeString = ''; let isNonNull = false; let isList = false; while (typeInfo) { if (typeInfo.kind === 'NON_NULL') { isNonNull = true; typeInfo = typeInfo.ofType; } else if (typeInfo.kind === 'LIST') { isList = true; typeInfo = typeInfo.ofType; } else { typeString = typeInfo.name || 'unknown'; break; } } let fullTypeString = ''; if (isList) { fullTypeString = `[${typeString}]`; } else { fullTypeString = typeString; } if (isNonNull) { fullTypeString += '!'; } return { name: field.name, type: fullTypeString, description: field.description || null, args: field.args?.length ? field.args : null }; }); const result = { table: { name: tableName, schema: schemaName, description: tableTypeResult.__type.description || null, columns: columnsInfo.sort((a: any, b: any) => a.name.localeCompare(b.name)) } }; return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } catch (error: any) { console.error(`[ERROR] Tool 'describe_table' failed: ${error.message}`); throw error; } } ); async function main() { console.log(`[INFO] Starting ${SERVER_NAME} v${SERVER_VERSION}...`); try { await getIntrospectionSchema(); } catch (error) { console.warn(`[WARN] Initial schema fetch failed...: ${error}`); } const transport = new StdioServerTransport(); console.log("[INFO] Connecting server to STDIO transport..."); await server.connect(transport); console.error(`[INFO] ${SERVER_NAME} v${SERVER_VERSION} connected and running via STDIO.`); } main().catch((error) => { console.error("[FATAL] Server failed to start or crashed:", error); process.exit(1); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/husamabusafa/hasura_mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server