Guide · MCP GraphQL Integration
Hasura MCP server — using Hasura's auto-generated GraphQL API as MCP tools
Hasura takes a PostgreSQL (or MySQL, SQL Server, MongoDB) schema and instantly generates a full CRUD GraphQL API — queries, mutations, subscriptions, aggregates, and relationships — without writing a single resolver. For teams that already use Hasura, it is a tempting backend for an MCP server: the query surface is already defined by your database schema, and Hasura's permission system provides row-level access control that can gate MCP tool access by user role. But Hasura's auto-generated schema is large and often too raw to expose directly as MCP tools. This page explains the three integration patterns for Hasura + MCP, how to use Hasura's permission system to scope MCP tool access per session, how to tune the connection pool for MCP workloads, and how to monitor Hasura health in a way that covers what Hasura's own /healthz endpoint misses.
TL;DR
Build a curated MCP tool surface on top of Hasura's GraphQL API — do not expose the raw schema directly. Use the x-hasura-role and x-hasura-user-id session variables as MCP authentication context. Monitor the MCP protocol layer separately from Hasura's /healthz — Hasura can be healthy while the MCP server is broken, and vice versa. AliveMCP covers the MCP protocol layer; pair it with Hasura Cloud metrics or self-hosted Prometheus for the GraphQL + database layer.
What Hasura generates for you
When you connect Hasura to a PostgreSQL database with, say, three tables — users, orders, and products — it instantly generates a GraphQL schema with:
- Query operations:
users,users_by_pk,users_aggregate,orders,orders_by_pk, and so on — one set per table - Mutation operations:
insert_users,insert_users_one,update_users,update_users_by_pk,delete_users,delete_users_by_pk— six per table - Subscription operations: the same set as queries, but streaming over WebSocket
- Relationship traversal: if
orders.user_idis a foreign key tousers.id, Hasura generates nested query access —orders { user { email } }
For three tables, that is roughly 40–60 generated operations. A raw MCP server that exposes all of them would give the LLM 40+ tools to choose from — a tool selection problem that produces unreliable results. The integration work is choosing which 10–20 of those operations to expose as MCP tools, writing good descriptions for each, and flattening the Hasura-specific input types (like users_bool_exp) into friendlier MCP tool arguments.
Three integration patterns
Pattern 1: Curated wrapper (recommended)
Write an MCP server that wraps specific Hasura queries and mutations as hand-curated MCP tools. You write one tool per use case — not one tool per Hasura operation:
import { GraphQLClient, gql } from "graphql-request";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const hasura = new GraphQLClient(process.env.HASURA_GRAPHQL_URL!, {
headers: {
"x-hasura-admin-secret": process.env.HASURA_ADMIN_SECRET!,
},
});
const server = new McpServer({ name: "hasura-mcp", version: "1.0.0" });
// Curated tool: fetch a user — wraps users_by_pk
server.tool(
"get_user",
"Fetch a user by ID. Returns email, name, plan tier, and account creation date.",
{ id: z.string().uuid().describe("User UUID") },
async ({ id }) => {
const data = await hasura.request(
gql`query GetUser($id: uuid!) {
users_by_pk(id: $id) { id email name plan created_at }
}`,
{ id }
);
if (!data.users_by_pk) {
return { content: [{ type: "text", text: `No user found with ID ${id}` }], isError: true };
}
return { content: [{ type: "text", text: JSON.stringify(data.users_by_pk, null, 2) }] };
}
);
// Curated tool: search users — wraps users with a where clause
server.tool(
"search_users",
"Search users by email or name (case-insensitive substring match). Returns up to 20 matching users.",
{ query: z.string().min(2).describe("Search term — matches against email and name fields") },
async ({ query }) => {
const data = await hasura.request(
gql`query SearchUsers($term: String!) {
users(
where: { _or: [{ email: { _ilike: $term } }, { name: { _ilike: $term } }] }
limit: 20
order_by: { created_at: desc }
) { id email name plan created_at }
}`,
{ term: `%${query}%` }
);
return { content: [{ type: "text", text: JSON.stringify(data.users, null, 2) }] };
}
);
This pattern gives you full control over the MCP tool surface. The LLM calls search_users with a simple query string — not a users_bool_exp object with _or, _ilike, and other Hasura-specific operators. The tool handler constructs the Hasura query internally.
Pattern 2: Schema introspection with tool generation
Use Hasura's GraphQL introspection to auto-generate MCP tools from selected query fields, then curate the results. This works well for data teams who want to expose a large number of tables without hand-writing each tool:
import { buildClientSchema, getIntrospectionQuery, GraphQLObjectType, GraphQLField } from "graphql";
async function generateHasuraMcpTools(
hasuraClient: GraphQLClient,
server: McpServer,
allowlist: string[] // only generate tools for these query fields
) {
const introspection = await hasuraClient.request(getIntrospectionQuery());
const schema = buildClientSchema(introspection);
const queryType = schema.getQueryType() as GraphQLObjectType;
for (const fieldName of allowlist) {
const field = queryType.getFields()[fieldName];
if (!field) {
console.warn(`Hasura schema does not have query field: ${fieldName}`);
continue;
}
// Only expose _by_pk fields as get-by-id tools — skip aggregate and connection fields
if (fieldName.endsWith("_aggregate") || fieldName.endsWith("_stream")) continue;
const toolName = fieldName.replace(/_by_pk$/, "_by_id").replace(/_/g, "_");
const description = field.description ?? `Query ${fieldName} from the database`;
server.tool(
toolName,
description,
buildZodFromHasuraArgs(field.args), // Convert Hasura arg types to Zod
async (args) => {
const data = await hasuraClient.request(
buildHasuraQuery(fieldName, field, args),
sanitizeHasuraArgs(args)
);
return { content: [{ type: "text", text: JSON.stringify(data[fieldName], null, 2) }] };
}
);
}
}
Pattern 3: Hasura Actions as MCP tools
Hasura Actions let you extend the auto-generated GraphQL API with custom business logic — REST endpoints or serverless functions that Hasura wraps as GraphQL mutations. Actions are a good source of MCP tools because they represent intentional, business-logic-level operations rather than raw CRUD:
// Hasura Action: chargeSubscription (calls your billing service)
// Defined in Hasura Console → Actions → Add Action
// Action handler: POST https://api.example.com/actions/charge-subscription
server.tool(
"charge_subscription",
"Charge a user's subscription for the current billing period. Returns the charge ID and amount. Only call this after confirming the user has a valid payment method.",
{
userId: z.string().uuid(),
planId: z.string().describe("Plan tier ID: free, author, or team"),
},
async ({ userId, planId }) => {
// Hasura Action is invoked as a GraphQL mutation
const data = await hasura.request(
gql`mutation ChargeSubscription($userId: uuid!, $planId: String!) {
chargeSubscription(userId: $userId, planId: $planId) {
chargeId
amountCents
currency
status
}
}`,
{ userId, planId }
);
return { content: [{ type: "text", text: JSON.stringify(data.chargeSubscription, null, 2) }] };
}
);
Hasura permission system as MCP authentication context
Hasura's role-based access control uses session variables passed in request headers to determine which rows and columns each user can access. The two key session variables are x-hasura-role (the user's role: user, admin, anonymous) and x-hasura-user-id (the authenticated user's UUID). Combined, they scope all queries to that user's data — an MCP tool that queries orders with x-hasura-user-id: abc123 can only see orders belonging to user abc123, enforced by Hasura row-level permissions.
// Extract auth context from MCP session and pass to Hasura headers
server.tool(
"list_my_orders",
"List the authenticated user's orders, newest first. Returns order ID, status, total cost, and creation date.",
{ limit: z.number().int().min(1).max(50).default(10) },
async ({ limit }, context) => {
// Auth token comes from MCP session context (set during initialize)
const { userId, role } = context.auth as { userId: string; role: string };
const sessionHasura = new GraphQLClient(process.env.HASURA_GRAPHQL_URL!, {
headers: {
"x-hasura-role": role ?? "user",
"x-hasura-user-id": userId,
},
});
const data = await sessionHasura.request(
gql`query ListMyOrders($limit: Int!) {
orders(
order_by: { created_at: desc }
limit: $limit
) {
id status total_cents created_at
}
}`,
{ limit }
);
// Hasura's row-level permission (configured in console) ensures this
// query only returns rows where user_id = x-hasura-user-id header value
return { content: [{ type: "text", text: JSON.stringify(data.orders, null, 2) }] };
}
);
Configure Hasura row-level permissions in the Hasura Console under Tables → Permissions. For the orders table with a user role, set the row filter to { "user_id": { "_eq": "X-Hasura-User-Id" } }. Hasura evaluates this filter on every query that uses the user role, so even if the LLM constructs a query that tries to access other users' orders, Hasura silently filters them out.
Using Hasura permissions as the MCP authorization layer means you don't need to re-implement access control in every MCP tool handler — Hasura enforces it at the database level regardless of how the query is constructed.
Connection pool tuning for MCP workloads
Hasura maintains a connection pool to PostgreSQL. The pool size determines how many concurrent database queries Hasura can execute simultaneously. MCP workloads have a different query pattern than typical web APIs:
| Workload characteristic | Web API (typical) | MCP server (typical) |
|---|---|---|
| Query concurrency | High — many users simultaneously | Lower — tool calls are sequential within one LLM reasoning chain |
| Query complexity | Often simple CRUD | Can be complex — LLMs sometimes construct multi-join queries |
| Query duration | Usually <100ms | Can be longer if tools do aggregations or scan large tables |
| Idle time | Low — continuous traffic | Higher — LLMs pause between tool calls for reasoning |
For a dedicated MCP server with 10–50 concurrent sessions, a Hasura connection pool of 10–20 connections is usually sufficient. Set HASURA_GRAPHQL_PG_CONNECTIONS and HASURA_GRAPHQL_PG_TIMEOUT appropriately:
# Hasura environment variables for MCP workload tuning
HASURA_GRAPHQL_PG_CONNECTIONS=15 # Pool size; start with 15, scale based on pg_stat_activity
HASURA_GRAPHQL_PG_TIMEOUT=30 # Query timeout seconds; matches MCP tool timeout budget
HASURA_GRAPHQL_PG_CONN_LIFETIME=600 # Recycle connections every 10 min to prevent stale TCP issues
HASURA_GRAPHQL_PG_POOL_TIMEOUT=30 # How long to wait for a connection from the pool
HASURA_GRAPHQL_PG_IDLE_TIMEOUT=180 # Close idle connections after 3 min to reduce PostgreSQL load
# For PostgreSQL server: set max_connections to pool_size + headroom
# If Hasura pool = 15 and you have 3 Hasura instances: max_connections = 60 + 20 buffer = 80
Monitor the connection pool with pg_stat_activity:
-- Check active connections and identify long-running queries
SELECT
state,
count(*) as connection_count,
max(extract(epoch from (now() - query_start))) as max_query_age_seconds
FROM pg_stat_activity
WHERE datname = 'your_database_name'
GROUP BY state;
If max_query_age_seconds is regularly above your MCP tool timeout threshold, you have slow queries that are tying up connection pool slots and will cause other tool calls to queue or fail.
Hasura health check vs MCP protocol health
Hasura exposes a /healthz endpoint that returns HTTP 200 when Hasura is running and connected to PostgreSQL. This is useful but does not cover the MCP protocol layer. The monitoring gaps:
| Failure | Hasura /healthz | MCP protocol probe (AliveMCP) |
|---|---|---|
| Hasura process crashed | Fails (HTTP down) | Fails (TCP refused or 5xx) |
| PostgreSQL connection lost | Fails (Hasura health check queries PG) | Fails only if tools are called; initialize still succeeds |
| MCP server process crashed | Passes (Hasura is separate service) | Fails (TCP refused or 5xx) |
| MCP initialize handler broken by bad deploy | Passes (not related to Hasura) | Fails (initialize probe returns error) |
| MCP tools/list returns empty after refactor | Passes | Fails (empty tools alert) |
| Hasura permission misconfiguration returning 401 | Passes (/healthz uses admin secret) | Passes (initialize uses MCP protocol, not GraphQL) |
Hasura permission misconfigurations — where the x-hasura-role permissions are wrong and every query returns a 401 or empty result — are invisible to both Hasura's /healthz and AliveMCP's MCP protocol probe. Catch this with tool-level error rate monitoring: alert when more than 5% of tool calls return isError: true over a 10-minute window.
The recommended monitoring stack for a Hasura-backed MCP server:
- AliveMCP — external MCP protocol probe (initialize + tools/list every 60s). Catches MCP server crashes, TLS issues, protocol regressions.
- Hasura /healthz polling — monitor with UptimeRobot or your existing HTTP monitor. Catches Hasura process crashes and database connection failures.
- Tool-level error rate metrics — structured logs per tool call with error flag. Catches permission issues, slow queries, and schema-drift failures that the first two layers miss.
Schema migrations and MCP tool surface
Hasura tracks database schema migrations and reruns them on startup. When a migration adds, removes, or renames a column, Hasura regenerates its GraphQL schema automatically. This can break MCP tool handlers that select specific columns in their queries:
-- Migration that renames a column
ALTER TABLE orders RENAME COLUMN total_cents TO total_amount_cents;
-- After this migration, any MCP tool that selects total_cents will receive a Hasura error:
-- field 'total_cents' not found in type 'orders'
Three practices to stay ahead of migration-driven breakage:
- Run MCP tool integration tests as part of your migration pipeline. After each migration, run a test suite that calls each MCP tool handler with a test fixture and verifies the response shape. A broken column selection fails the test before the migration goes to production.
- Use Hasura's schema diff tooling.
hasura migrate statusandhasura metadata diffshow what changed between migration versions. Review the diff for column removals or renames that affect your MCP tool query selections. - Keep MCP tool queries in separate files, not inline strings. Named query files are easier to review in PR diffs than embedded
gqltemplate literals. A PR that renames a column is much easier to review when the reviewer can see which query files need updating.
Related questions
Can I use Hasura Cloud instead of self-hosted Hasura with an MCP server?
Yes. Hasura Cloud exposes the same GraphQL API as self-hosted Hasura — the integration is identical from the MCP server's perspective. Hasura Cloud has built-in connection pool management and monitoring dashboards, which reduces operational overhead. The main trade-off: Hasura Cloud pricing is based on query volume, so MCP workloads with high tool-call frequency may incur higher costs than self-hosted. Check Hasura Cloud's cost structure against your expected MCP query volume before choosing.
Should I expose Hasura's aggregate fields (users_aggregate, orders_aggregate) as MCP tools?
Selectively — aggregate queries are useful when the LLM needs counts, sums, or averages. A count_users_by_plan tool that wraps users_aggregate { nodes { plan } aggregate { count } } grouped by plan is genuinely useful. Avoid exposing raw *_aggregate fields with the full Hasura aggregate input type — the users_aggregate_bool_exp argument type is too complex for reliable LLM use. Write a tool with a simple parameter (e.g., plan: z.enum(["free", "author", "team"]).optional()) and build the aggregate query internally.
Can I use Hasura Remote Schemas alongside Hasura's auto-generated schema in an MCP server?
Yes. Remote Schemas in Hasura let you merge an external GraphQL API (e.g., a Stripe API wrapper, a search service, or a custom microservice) into Hasura's unified schema. From your MCP server's perspective, Remote Schema fields are just more query operations in the Hasura GraphQL API. You can wrap them as MCP tools the same way you wrap auto-generated CRUD operations. The main consideration: Remote Schema queries bypass Hasura's row-level permissions — the remote GraphQL service is responsible for its own access control.
How do I handle Hasura's cursor-based pagination in MCP tools?
Hasura generates *_connection queries with Relay-spec cursor pagination when you enable it in the Hasura Console (Tables → Configure). The connection type exposes edges { cursor node { ... } } and pageInfo { hasNextPage endCursor }. Wrap this in an MCP tool that accepts an after cursor parameter and returns both the results and the next page cursor. The LLM can call the tool again with the cursor to paginate. Cap the first argument at 20–50 to control payload size.
Further reading
- MCP server with GraphQL — wrapping a GraphQL API as MCP tools
- MCP server GraphQL schema design — controlling tool surface from auto-generated schemas
- MCP server with Apollo Client — querying GraphQL APIs from MCP tool handlers
- MCP server PostgreSQL — direct database access from MCP tools
- MCP server authentication — session-scoped credentials and token passing
- MCP server database tools — patterns for exposing database operations safely
- MCP server timeout — query timeout configuration and monitoring
- AliveMCP — MCP protocol monitoring for Hasura-backed servers; pairs with Hasura /healthz and tool-level error metrics for full coverage