Guide · MCP GraphQL Integration
MCP server GraphQL schema design — structuring types for MCP tool compatibility
The GraphQL schema is the contract between your data layer and the clients that query it. When you build an MCP server that wraps a GraphQL API, the schema also becomes the contract between your tool definitions and the language model that calls them. Schema choices that work fine for a browser client — deeply nested union types, polymorphic interfaces, paginated connections returning hundreds of nodes — create poor MCP tool experiences. The LLM has a finite context window and expects tool inputs to be simple enough to construct without schema documentation. This page covers the schema design decisions that most directly affect MCP tool quality: generating input schemas from GraphQL arguments, controlling response payload size, handling union types and interfaces, detecting schema drift, and managing breaking changes without surprising the models that rely on your tools.
TL;DR
Prefer scalar and enum arguments over nested input objects in tool inputs. Cap list queries at 20–50 results. Flatten union types at the MCP layer so the LLM receives consistent response shapes. Version your MCP tool surface independently from your GraphQL schema version. Monitor tool surface hash changes with AliveMCP to detect schema drift before it breaks agent pipelines.
Generating MCP inputSchema from GraphQL arguments
Every MCP tool has an inputSchema — a JSON Schema object describing the tool's parameters. When you build a tool around a GraphQL query or mutation, the inputSchema should map naturally from the GraphQL operation's arguments. This mapping is mechanical for scalar types but requires decisions for complex input types:
| GraphQL argument type | MCP inputSchema type | Notes |
|---|---|---|
String | { "type": "string" } | Direct mapping |
Int | { "type": "integer" } | Direct mapping |
Float | { "type": "number" } | Direct mapping |
Boolean | { "type": "boolean" } | Direct mapping |
ID | { "type": "string" } | Always string at the MCP layer even if stored as integer |
enum PlanTier { FREE AUTHOR TEAM } | { "type": "string", "enum": ["free", "author", "team"] } | Lowercase enum values are more readable for LLMs than ALL_CAPS |
input DateRange { start: String! end: String! } | Flatten to two params: startDate: string, endDate: string | Avoid nested input objects — they confuse LLMs about argument structure |
[String!]! | { "type": "array", "items": { "type": "string" } } | Cap max items if unbounded in GraphQL |
The most important rule: flatten nested input objects at the MCP tool layer. A GraphQL mutation like createOrder(input: OrderInput!) where OrderInput has five fields should become an MCP tool with five top-level parameters, not one input parameter that is itself an object. LLMs construct tool call arguments more reliably when all parameters are at the top level.
// GraphQL mutation definition
// createOrder(input: OrderInput!) — OrderInput has productId, quantity, addressId, notes, couponCode
// BAD — nested input object
server.tool("create_order", "...", {
input: z.object({
productId: z.string(),
quantity: z.number().int().min(1),
addressId: z.string(),
notes: z.string().optional(),
couponCode: z.string().optional(),
}),
}, async ({ input }) => { /* ... */ });
// GOOD — flattened top-level parameters
server.tool("create_order", "...", {
productId: z.string().describe("Product ID from list_products"),
quantity: z.number().int().min(1).max(100).describe("Number of units to order"),
addressId: z.string().describe("Shipping address ID from list_addresses"),
notes: z.string().optional().describe("Optional delivery instructions"),
couponCode: z.string().optional().describe("Optional discount coupon code"),
}, async ({ productId, quantity, addressId, notes, couponCode }) => {
// Reconstruct the input object for the GraphQL call
const data = await client.request(CREATE_ORDER_MUTATION, {
input: { productId, quantity, addressId, notes, couponCode },
});
/* ... */
});
Handling GraphQL union types and interfaces in tool responses
GraphQL union types and interfaces let a field return one of several concrete types — a SearchResult that can be a User, a Product, or an Order. This is elegant in a typed GraphQL client that uses code generation to handle each type. In an MCP tool response, polymorphic shapes require extra work from the LLM to interpret.
Three options for handling unions at the MCP layer:
- One tool per concrete type. Instead of a
searchtool that returns a union, providesearch_users,search_products, andsearch_orders. Each returns a consistent shape. The LLM chooses the right tool based on what it's looking for. - Flatten to a common envelope. Serialize each result with a
typediscriminator field and the relevant fields:{ type: "user", id: "...", name: "...", email: "..." }. This gives the LLM a consistent outer shape while preserving the type information. - Return raw GraphQL JSON. Let the LLM interpret the
__typenamefield and handle each type. This works if the LLM is instructed to handle polymorphic results, but it creates fragile tool contracts that break when new types are added to the union.
// Pattern 2: Flatten union types to a common envelope
const SEARCH_QUERY = gql`
query Search($term: String!) {
search(term: $term) {
__typename
... on User { id email name }
... on Product { id name priceCents category }
... on Order { orderId status createdAt totalCents }
}
}
`;
server.tool(
"search",
"Search across users, products, and orders. Returns up to 20 results, each with a 'type' field indicating the result kind and the key fields for that type.",
{ term: z.string().min(2).describe("Search term (minimum 2 characters)") },
async ({ term }) => {
const data = await client.request<{ search: SearchResult[] }>(SEARCH_QUERY, { term });
const normalized = data.search.map((result) => {
switch (result.__typename) {
case "User": return { type: "user", id: result.id, name: result.name, email: result.email };
case "Product": return { type: "product", id: result.id, name: result.name, priceCents: result.priceCents, category: result.category };
case "Order": return { type: "order", id: result.orderId, status: result.status, createdAt: result.createdAt };
default: return { type: "unknown", ...result };
}
});
return { content: [{ type: "text", text: JSON.stringify(normalized, null, 2) }] };
}
);
Controlling response payload size for LLM context budgets
A GraphQL query can return arbitrarily large payloads if you don't constrain selection sets and pagination. In MCP context, large tool responses have two costs: they consume LLM context that could be used for reasoning, and they increase tool call latency. The guidelines:
- Cap list results at 20 items by default. Use a
limitparameter with a default of 10 and a max of 50. The LLM can call the tool again with a cursor if it needs more results. - Select only the fields the LLM can act on. Internal IDs, audit timestamps, system metadata, and computed fields that the LLM can't use as input to another tool are payload waste. Write selection sets that return the 5–10 fields that matter.
- Separate summary tools from detail tools. A list tool returns enough to identify a record (ID + key name + status). A detail tool returns the full record when the LLM needs specifics. The LLM can choose the detail tool when needed rather than always paying the cost of a full response.
A rough heuristic: if a single tool call response exceeds 2,000 tokens (~8,000 characters of JSON), consider splitting it into a summary + detail pattern or adding more restrictive filtering options to the tool's input schema.
Schema drift: when GraphQL changes break MCP tools
GraphQL APIs evolve. Fields get removed, argument types change, return types gain new required fields, enums get new values. Any of these can silently break your MCP tool handlers — the initialize and tools/list probes still succeed, but tool calls fail at runtime when the LLM calls the affected tool.
Schema drift has two manifestations:
- Breaking changes at the GraphQL layer: The upstream API removes a field you select, or changes an argument type your tool passes. Tool calls start returning
isError: true. The LLM stops trusting the tool. - Breaking changes at the MCP tool surface layer: You update the tool to match the new GraphQL schema, which means changing tool parameter names, removing parameters, or changing the response structure. Any agent pipeline built against the old tool surface breaks.
Four practices to manage schema drift:
// 1. Pin your GraphQL queries to specific fragments
// Use persisted queries or query hashing to detect when upstream API changes
// would break your selection sets
// 2. Add a schema version check to server startup
async function verifySchemaCompatibility(client: GraphQLClient) {
const introspection = await client.request(getIntrospectionQuery());
const schema = buildClientSchema(introspection);
const queryType = schema.getQueryType();
const requiredFields = ["user", "orders", "products"];
for (const fieldName of requiredFields) {
if (!queryType?.getFields()[fieldName]) {
throw new Error(`Required GraphQL field '${fieldName}' not found in schema. Schema may have changed.`);
}
}
}
// 3. Hash your tool surface and alert on changes
import crypto from "crypto";
function computeToolSurfaceHash(server: McpServer): string {
const tools = server.listTools(); // hypothetical method
const sorted = tools.map((t) => ({ name: t.name, schema: t.inputSchema })).sort((a, b) => a.name.localeCompare(b.name));
return crypto.createHash("sha256").update(JSON.stringify(sorted)).digest("hex");
}
// 4. Version your tool surface separately from your GraphQL API version
// Use a tool name prefix if you need to ship breaking MCP changes alongside GraphQL changes:
// "get_user_v2" can coexist with "get_user" during transition
AliveMCP monitors your tools/list response on every probe and alerts on schema drift — when the hash of your tool array changes unexpectedly between probes. This catches unintended tool surface changes from bad deploys before users encounter broken tool calls in their agent pipelines.
Tool descriptions derived from GraphQL documentation strings
GraphQL schemas support documentation strings on types, fields, and arguments using triple-quoted strings. These strings are accessible via introspection. When auto-generating MCP tools from a schema, you can use these descriptions as a starting point for tool descriptions:
# GraphQL schema with documentation strings
type Query {
"""
Fetch a user record by their unique identifier. Returns profile fields including
plan tier and account creation date. Returns null if no user exists with the given ID.
"""
user(
"""The user's unique identifier (UUID format)"""
id: ID!
): User
"""
List orders for the authenticated user, sorted by creation date descending.
Use the cursor argument for pagination.
"""
orders(
"""Maximum number of orders to return (1–50)"""
first: Int = 10
"""Pagination cursor from previous response's pageInfo.endCursor"""
after: String
): OrderConnection!
}
GraphQL docstrings are a good foundation but are written for developer-audience API documentation, not for LLM tool selection. Two common gaps:
- Missing action context: "Fetch a user record" is less useful to a model than "Fetch a user's profile, plan tier, and creation date by their ID. Use this when you need to look up a specific user's details." The MCP description should explain when to call the tool, not just what it does.
- Missing cross-tool dependencies: A tool description should mention when its output is the input to another tool. "Returns the user ID needed for the update_user_plan and list_user_orders tools" helps the LLM plan multi-step operations.
Related questions
Should I expose every GraphQL query and mutation as an MCP tool?
No. Expose only the operations that an LLM can meaningfully choose between and execute correctly. Avoid exposing introspection queries, admin mutations that could cause irreversible damage, and operations that require complex multi-step context that a single tool call can't provide. Aim for 10–30 well-documented tools that cover the primary use cases, not a comprehensive 1:1 mapping of your entire GraphQL schema.
How do I handle GraphQL schema changes without breaking existing agent pipelines?
Treat breaking MCP tool surface changes the same way you treat breaking API changes: version them. Use a versioned tool name suffix (get_user_v2) or a versioned MCP server endpoint (/mcp/v2) to let agent pipelines migrate at their own pace. Deprecate old tools explicitly in their descriptions ("Deprecated: use get_user_v2 instead") rather than silently removing them.
Can I use GraphQL introspection to auto-generate MCP tool descriptions?
Yes, but treat auto-generated descriptions as first drafts. Introspection gives you the raw documentation strings from the schema, which are useful but not optimized for LLM tool selection. Plan to curate the most important tools with hand-written descriptions that explain when to use the tool, what the result means, and how it relates to other tools. Auto-generation is valuable for getting something working quickly; curation is what makes the tools reliable in production agent pipelines.
What happens to MCP tool contracts when my GraphQL API removes a field?
If the removed field is in your selection set, the GraphQL query returns an error or omits the field, depending on whether the server treats it as a breaking change. If it's in your tool's inputSchema as an argument that was previously passed to the API, the query may succeed but the argument is ignored — which can cause confusing tool behavior where the input parameter appears to work but has no effect. Add a startup check that validates your critical queries against the live schema to catch this before users do.
Further reading
- MCP server with GraphQL — wrapping a GraphQL API as MCP tools
- MCP server with Apollo Client — caching and error handling in GraphQL-backed MCP tools
- MCP server tool design — naming, descriptions, and inputSchema best practices
- MCP server tool discovery — how clients find and use your tool surface
- MCP server Zod validation — runtime input validation for MCP tools
- MCP server deployment — zero-downtime deploys and post-deploy verification
- AliveMCP — tool surface hash monitoring to detect schema drift between deploys