Guide · MCP GraphQL Integration
MCP server with GraphQL — wrapping a GraphQL API as MCP tools
GraphQL and MCP are a natural pairing: GraphQL gives you a typed schema that describes every operation your data layer can perform; MCP gives you a structured way to expose those operations to language models. Building an MCP server that wraps a GraphQL API means you get type-safe tool inputs via GraphQL's argument system, rich response shapes that LLMs can reason about, and schema introspection as a basis for automated tool generation. But the combination also creates a two-layer protocol stack — the MCP protocol on top, GraphQL queries on the bottom — and each layer can fail in ways the other layer cannot see. This page explains how to map GraphQL operations to MCP tools, handle errors correctly at each layer, avoid the N+1 performance trap, and monitor the whole stack so you catch failures before your users do.
TL;DR
Map each GraphQL query or mutation to one MCP tool. Use GraphQL argument types to generate inputSchema properties. Map GraphQL errors to MCP isError: true responses. Use DataLoader to batch upstream requests when multiple tools run per session. Monitor the MCP protocol layer with AliveMCP — HTTP 200 from your server does not mean GraphQL queries are succeeding.
Mapping GraphQL operations to MCP tools
The most direct mapping is one GraphQL operation per MCP tool. A query that fetches a user by ID becomes a get_user tool; a mutation that creates an order becomes a create_order tool. This keeps tool responsibilities narrow and makes it easier for the LLM to choose the right tool.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { GraphQLClient, gql } from "graphql-request";
import { z } from "zod";
const client = new GraphQLClient("https://api.example.com/graphql", {
headers: { Authorization: `Bearer ${process.env.GRAPHQL_API_KEY}` },
});
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
email
name
plan
createdAt
}
}
`;
const server = new McpServer({ name: "my-graphql-mcp", version: "1.0.0" });
server.tool(
"get_user",
"Fetch a user record by ID. Returns id, email, name, plan tier, and account creation date.",
{ id: z.string().describe("The user's unique ID") },
async ({ id }) => {
const data = await client.request(GET_USER, { id });
return {
content: [{ type: "text", text: JSON.stringify(data.user, null, 2) }],
};
}
);
The key design decisions are in the tool description and the response shape. Write tool descriptions that explain what data the tool returns and in what form — the LLM uses this to decide whether to call the tool and how to interpret the result. Return a serialized JSON object rather than a prose sentence when the caller will need to extract structured fields from the result.
For mutations, structure the tool to accept the minimum inputs required and return confirmation of what changed:
const CREATE_ORDER = gql`
mutation CreateOrder($productId: ID!, $quantity: Int!, $addressId: ID!) {
createOrder(productId: $productId, quantity: $quantity, addressId: $addressId) {
orderId
status
estimatedDelivery
totalCents
}
}
`;
server.tool(
"create_order",
"Place a new order. Requires a product ID, quantity (1-100), and shipping address ID. Returns the new order ID, status, estimated delivery date, and total cost in cents.",
{
productId: z.string().describe("Product to order"),
quantity: z.number().int().min(1).max(100).describe("Number of units"),
addressId: z.string().describe("Shipping address ID from list_addresses tool"),
},
async ({ productId, quantity, addressId }) => {
const data = await client.request(CREATE_ORDER, { productId, quantity, addressId });
return {
content: [{ type: "text", text: JSON.stringify(data.createOrder, null, 2) }],
};
}
);
Controlling response payload size
GraphQL makes it easy to return deeply nested objects with dozens of fields. In a browser or mobile client, a large response is a minor latency issue. In an MCP context, every byte of tool output goes into the LLM's context window. A tool that returns 50KB of nested JSON is burning context budget that the model needs for reasoning about the response — and it strains your server's performance because the MCP transport must frame and deliver the whole payload.
Two practices keep response payloads manageable:
- Use GraphQL fragments to select only fields the LLM needs. The LLM does not need internal database IDs, audit timestamps, or computed values it can't act on. Write tight selection sets that return what a human would need to understand the record.
- Provide separate detail-fetch tools. Return a summary (ID + key identifiers + status) from list operations, and a separate
get_*_detailtool for when the LLM needs the full record. This keeps list calls fast and cheap.
// Summary tool — fast list, small payload
server.tool(
"list_orders",
"List recent orders. Returns order ID, status, and creation date for each order. Use get_order_detail for full shipping and line-item information.",
{ limit: z.number().int().min(1).max(50).default(10) },
async ({ limit }) => {
const data = await client.request(
gql`query ListOrders($limit: Int!) {
orders(first: $limit) {
nodes { orderId status createdAt }
}
}`,
{ limit }
);
return { content: [{ type: "text", text: JSON.stringify(data.orders.nodes) }] };
}
);
// Detail tool — full record, called only when needed
server.tool(
"get_order_detail",
"Fetch full details for a single order including line items, shipping address, and tracking information.",
{ orderId: z.string() },
async ({ orderId }) => {
const data = await client.request(
gql`query GetOrderDetail($orderId: ID!) {
order(id: $orderId) {
orderId status createdAt estimatedDelivery
lineItems { productName quantity unitPriceCents }
shippingAddress { street city country postalCode }
tracking { carrier trackingNumber url }
}
}`,
{ orderId }
);
return { content: [{ type: "text", text: JSON.stringify(data.order, null, 2) }] };
}
);
Handling GraphQL errors in MCP responses
GraphQL has a unique error model that you must explicitly handle when building an MCP wrapper. A GraphQL response can return HTTP 200 while also containing an errors array — this is a partial success, not an HTTP failure. Standard HTTP monitors (and basic uptime checks) will report your server as healthy even when every GraphQL query is returning errors.
Map GraphQL error responses to MCP's isError: true convention:
import { ClientError } from "graphql-request";
async function executeQuery<T>(query: string, variables: Record<string, unknown>): Promise<T> {
try {
return await client.request<T>(query, variables);
} catch (err) {
if (err instanceof ClientError) {
// GraphQL errors array — server responded but query failed
const messages = err.response.errors?.map((e) => e.message).join("; ") ?? "GraphQL error";
throw new McpGraphQLError(messages, err.response.errors);
}
// Network or transport error
throw err;
}
}
// In your tool handler:
server.tool("get_user", "...", { id: z.string() }, async ({ id }) => {
try {
const data = await executeQuery<{ user: User }>(GET_USER, { id });
if (!data.user) {
return {
content: [{ type: "text", text: `No user found with ID ${id}` }],
isError: true,
};
}
return { content: [{ type: "text", text: JSON.stringify(data.user, null, 2) }] };
} catch (err) {
const message = err instanceof Error ? err.message : "GraphQL query failed";
return { content: [{ type: "text", text: message }], isError: true };
}
});
Three error categories to handle explicitly:
| Error category | HTTP status | GraphQL errors array | MCP response |
|---|---|---|---|
| Not found | 200 | No (null in data) | isError: true, descriptive message |
| Validation error | 200 | Yes | isError: true, errors joined as message |
| Unauthorized | 401 or 200 with error | Maybe | isError: true, do not leak auth details |
| Rate limited | 429 | No | isError: true, include retry hint if available |
| Server error | 500 | Maybe | isError: true, log full error server-side |
The N+1 problem in MCP tool handlers
In a standard GraphQL server, the N+1 problem arises when a list query triggers one database query per item. In an MCP server that wraps a GraphQL API, you face a different variant: the LLM may call your MCP tools in a loop — fetching 20 user records one at a time — and each call triggers a separate GraphQL request upstream. Without batching, this creates 20 sequential round-trips where one batched query could suffice.
The solution depends on what your upstream GraphQL API supports:
- Provide a batch-fetch tool. Add a
get_users_batchtool that accepts an array of IDs and executes one GraphQL query with anids: [ID!]!argument. Document it explicitly so the LLM knows to prefer it over callingget_userin a loop. - Use DataLoader for field-level batching. If the LLM cannot be expected to call batch tools, implement DataLoader inside your MCP server to batch and cache upstream GraphQL requests within a single session.
import DataLoader from "dataloader";
// Create one DataLoader per MCP session, not per tool call
function createSessionLoaders(graphqlClient: GraphQLClient) {
const userLoader = new DataLoader<string, User>(async (ids) => {
const data = await graphqlClient.request<{ usersByIds: User[] }>(
gql`query GetUsersBatch($ids: [ID!]!) { usersByIds(ids: $ids) { id email name plan } }`,
{ ids }
);
// DataLoader requires results in the same order as keys
const userMap = new Map(data.usersByIds.map((u) => [u.id, u]));
return ids.map((id) => userMap.get(id) ?? new Error(`User ${id} not found`));
});
return { userLoader };
}
// In session setup (if your MCP server has session lifecycle hooks):
const sessions = new Map<string, ReturnType<typeof createSessionLoaders>>();
server.tool("get_user", "...", { id: z.string() }, async ({ id }, { sessionId }) => {
let loaders = sessions.get(sessionId);
if (!loaders) {
loaders = createSessionLoaders(client);
sessions.set(sessionId, loaders);
}
const user = await loaders.userLoader.load(id);
return { content: [{ type: "text", text: JSON.stringify(user, null, 2) }] };
});
DataLoader batches all load() calls made within the same Node.js event loop tick into a single batch request. If the LLM calls get_user for five IDs in rapid succession, DataLoader coalesces them into one usersByIds query. The cache ensures that if the same ID is requested twice in one session, the second request returns the cached value without a network call.
Schema introspection for automatic tool generation
GraphQL's introspection system lets you query the schema itself — every type, field, argument, and description. This is a foundation for automatically generating MCP tools from a GraphQL schema rather than hand-writing each one. The approach works well for data-access MCP servers where the tool surface maps closely to a GraphQL schema:
import { buildClientSchema, getIntrospectionQuery, GraphQLObjectType } from "graphql";
async function generateToolsFromSchema(client: GraphQLClient, server: McpServer) {
const introspectionResult = await client.request(getIntrospectionQuery());
const schema = buildClientSchema(introspectionResult);
const queryType = schema.getQueryType();
if (!queryType) return;
for (const [fieldName, field] of Object.entries(queryType.getFields())) {
const toolName = fieldName; // e.g. "user", "orders", "product"
const description = field.description ?? `Query ${fieldName} from the GraphQL API`;
// Build Zod schema from GraphQL args
const inputSchema = buildZodFromGraphQLArgs(field.args);
server.tool(toolName, description, inputSchema, async (args) => {
const query = buildQueryFromField(fieldName, field, args);
const data = await client.request(query, args);
return { content: [{ type: "text", text: JSON.stringify(data[fieldName], null, 2) }] };
});
}
}
The practical limitation: auto-generated tools inherit the verbosity of the GraphQL schema. Fields with generic names ("node", "connection") and terse descriptions produce poor tool descriptions that confuse the LLM. Use auto-generation as a starting point, then curate the 10–20 tools the LLM will actually need, and write hand-tuned descriptions for each.
Monitoring the two-layer stack
An MCP server that wraps GraphQL has two distinct failure modes that require two distinct monitoring strategies:
| Layer | What can fail | What a basic HTTP monitor sees | What you need |
|---|---|---|---|
| MCP protocol | Server down, initialize fails, tools/list empty, transport error | TCP failure or HTTP 5xx | External protocol probe (AliveMCP) |
| GraphQL backend | API key expired, schema changed, resolver errors, upstream service down | HTTP 200 (MCP server responds normally) | Tool-level error rate monitoring |
The GraphQL layer is the invisible one. Your MCP server's initialize and tools/list endpoints will respond correctly even if the GraphQL API behind them is completely broken — they don't execute queries. From the outside, your server looks healthy. Users experience errors only when they actually call a tool.
Two monitoring practices close this gap:
- Emit structured logs per tool call with
tool_name,graphql_errors,duration_ms, andsuccess. Set up an alert when the GraphQL error rate for any tool exceeds 5% over a 10-minute window. - Use AliveMCP for external protocol monitoring. AliveMCP probes your MCP
initializeandtools/listsequence every 60 seconds. This catches transport-layer failures, TLS expiry, server crashes, and protocol regressions — the things that make your server completely unreachable — before your users encounter them and before any registry re-crawl discovers the outage.
For production MCP servers, especially those listed on Smithery, Glama, or other MCP registries, external protocol monitoring is not optional. Registries re-crawl your server periodically and demote or remove listings that fail their verification sequence. AliveMCP's continuous probing ensures you know about failures within 3 minutes — not when the next weekly crawl discovers the problem.
Related questions
Should I use graphql-request, Apollo Client, or fetch directly?
For MCP servers, graphql-request is the simplest choice — it is a thin wrapper that sends queries and returns typed results, with minimal overhead. Use Apollo Client when you need normalized caching (avoiding duplicate network calls for the same data across multiple tool invocations in one session) or when you need subscription support. Use raw fetch only if you need complete control over request construction and want to avoid any dependency.
Can I auto-generate MCP tools from a GraphQL schema using introspection?
Yes, introspection-based tool generation is viable for CRUD-heavy APIs where field names and descriptions are already well-written. The result is a working tool surface, but the tool descriptions and input schemas often need curation before the LLM can use them reliably. Think of auto-generation as scaffolding, not a finished product.
How do I handle GraphQL pagination in MCP tools?
Cursor-based pagination (Relay spec: first, after, edges, pageInfo) works well in MCP tools. Accept first and after as tool inputs and return pageInfo.hasNextPage and pageInfo.endCursor in your response. The LLM can then call the tool again with after: endCursor when it needs more results. Cap first at a reasonable limit (20–50) in your input schema to avoid payload size issues.
What happens when the GraphQL schema changes and breaks my MCP tools?
Breaking changes in the GraphQL schema — removed fields, renamed arguments, changed types — will cause your MCP tool handlers to fail at runtime when the LLM calls the affected tool. The initialize and tools/list probes will still succeed, so external monitoring won't catch this automatically. Add integration tests that call each tool handler with a real GraphQL request as part of your deployment pipeline. See MCP server GraphQL schema design for versioning strategies.
Further reading
- MCP server with Apollo Client — Apollo setup, caching, and error handling in MCP tool handlers
- MCP server GraphQL schema design — structuring types and queries for MCP tool compatibility
- MCP server GraphQL subscriptions — real-time data via subscriptions in MCP tools
- Hasura MCP server — auto-generated GraphQL as MCP tools
- MCP server tool design — naming, descriptions, and input schemas
- MCP server error rate — measuring and alerting on tool-level errors
- MCP server observability — structured logs, metrics, and tracing
- MCP server performance — payload size, concurrency, and resource sizing
- AliveMCP — 60-second external protocol monitoring so GraphQL backend failures don't silently kill your MCP server listing