Guide · GraphQL API
MCP Tools for Apollo Server — DataLoader batching, query complexity limits, persisted queries
Three Apollo Server behaviours trip up MCP tool authors: DataLoader instances must be created inside the context factory per request, never shared across requests — a singleton DataLoader caches results from request A and serves them to request B, leaking data across users; the cache key is whatever you pass to load(), so two different users loading the same database row ID will get the first user's cached result until the batch window expires; depth-limiting alone does not prevent query abuse — graphql-depth-limit blocks deeply nested queries but allows queries that select thousands of sibling fields at the same depth level, which causes identical database fan-out; use graphql-query-complexity with a per-operation cost budget that assigns weights to list fields and compounds them multiplicatively; and Automatic Persisted Queries (APQ) require a server-side cache to be configured — if you rely on APQ to reduce request payload size in your MCP transport but start Apollo Server without a persistedQueries: { cache } configuration, the server accepts full queries but rejects hash-only APQ requests with PersistedQueryNotFound, breaking clients that send hash-first.
TL;DR
Create DataLoaders inside your context factory: context: ({ req }) => ({ userLoader: new DataLoader(batchUsers) }). Add complexity limits: validationRules: [createComplexityLimitRule(500)]. Enable APQ with an in-memory cache: persistedQueries: { cache: new InMemoryLRUCache() }. Health probe: POST /graphql with {"query":"{ __typename }"} — expect {"data":{"__typename":"Query"}}.
Per-request DataLoader instances in the context factory
DataLoader batches multiple individual load() calls within a single tick of the event loop into one batch function call. The batch function receives an array of keys and must return a Promise that resolves to an array of values in the same order and same length. The critical constraint for MCP tool servers: DataLoader also caches: once a key is resolved, subsequent load(key) calls return the cached value without calling the batch function again.
In Apollo Server v4, the context function is called once per HTTP request. Create fresh DataLoader instances there — never at module level or as a server-level singleton.
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import DataLoader from 'dataloader';
import { db } from './db';
// Batch function: receives array of user IDs, returns array of users in same order
async function batchUsers(ids: readonly string[]) {
const users = await db.query(
'SELECT * FROM users WHERE id = ANY($1)',
[ids]
);
// CRITICAL: result must be same length and same order as ids
// A plain users.rows array may skip missing IDs or reorder — index by ID first
const byId = new Map(users.rows.map((u: { id: string }) => [u.id, u]));
return ids.map(id => byId.get(id) ?? new Error(`User ${id} not found`));
}
// Context factory — called once per request
// Returning a new DataLoader instance here isolates per-request caching
interface Context {
userLoader: DataLoader<string, { id: string; name: string; email: string }>;
viewerId: string | null;
}
const server = new ApolloServer<Context>({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, {
context: async ({ req }): Promise<Context> => {
const viewerId = extractViewerId(req.headers.authorization);
return {
// New DataLoader per request — cache is isolated to this request
userLoader: new DataLoader(batchUsers),
viewerId,
};
},
});
// In a resolver, use context.userLoader — not a module-level loader
const resolvers = {
Post: {
author: (post: { authorId: string }, _: unknown, context: Context) => {
// All author lookups in one request are batched together
return context.userLoader.load(post.authorId);
},
},
};
A module-level DataLoader would serve cached results from prior requests. For an MCP server handling multiple users' queries, this means user A's data is returned to user B if they query the same ID within the cache TTL. DataLoader has no built-in TTL — it caches indefinitely until garbage-collected with the server process. Per-request instances ensure cache lifetime matches request lifetime.
If your batch function queries an external API with rate limits, configure DataLoader's batch and cache options explicitly:
// DataLoader options for rate-limited external API calls
const apiLoader = new DataLoader<string, ExternalRecord>(batchFetch, {
// Batch up to 100 keys per tick (external API limit)
maxBatchSize: 100,
// Batch window: wait up to 16ms for more keys before firing
// Default is next tick (0ms); increase if resolvers are async and stagger slightly
batchScheduleFn: (callback) => setTimeout(callback, 16),
// Disable caching if results must be fresh per-field (rare — adds latency)
// cache: false,
// Custom cache key: normalise to lowercase to collapse case variants
cacheKeyFn: (key) => key.toLowerCase(),
});
// Priming the cache: if you already have the data, skip the network call
// Useful when a parent resolver fetches a record that children will also load
function primeUsersCache(loader: DataLoader<string, User>, users: User[]) {
users.forEach(u => loader.prime(u.id, u));
}
Query complexity limits — depth vs width vs cost budgets
A depth limit of 5 blocks user { friends { friends { friends { friends { friends { id } } } } } } but allows { users(first: 1000) { id } } — a single field at depth 2 that hits every row. Complexity-based limiting assigns a cost to each field and compounds list field costs by their expected cardinality multiplier.
import { ApolloServer } from '@apollo/server';
import { createComplexityLimitRule } from 'graphql-query-complexity';
import depthLimit from 'graphql-depth-limit';
// graphql-query-complexity: install with npm install graphql-query-complexity
// Two rule variants: simpleEstimator (flat cost) and fieldExtensionsEstimator (per-field config)
import {
simpleEstimator,
fieldExtensionsEstimator,
getComplexity,
} from 'graphql-query-complexity';
const complexityRule = (query: DocumentNode, variables: Record<string, unknown>) => {
const complexity = getComplexity({
schema,
query,
variables,
estimators: [
// Field extension estimator reads cost from field definition extensions
fieldExtensionsEstimator(),
// Fallback: 1 point per field (catches fields without explicit cost)
simpleEstimator({ defaultComplexity: 1 }),
],
});
if (complexity > 500) {
throw new Error(
`Query complexity ${complexity} exceeds maximum of 500. ` +
`Reduce the number of fields or paginate with smaller first/limit values.`
);
}
};
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
// Depth limit: max nesting depth (complements complexity, doesn't replace it)
depthLimit(8),
// Complexity rule: called before execution
complexityRule,
],
});
// In your schema, annotate list fields with expected complexity multipliers:
// Using SDL extensions (works with fieldExtensionsEstimator):
const typeDefs = gql\`
type Query {
users(first: Int): [User!]!
user(id: ID!): User
}
type User {
id: ID!
name: String!
posts(first: Int): [Post!]!
}
\`;
// Attach complexity cost in resolver map extensions:
const resolvers = {
Query: {
users: {
resolve: (_: unknown, args: { first?: number }) => db.users.findMany({ take: args.first }),
// extensions are passed to fieldExtensionsEstimator
extensions: {
complexity: ({ args, childComplexity }: { args: { first?: number }; childComplexity: number }) =>
// Each user costs 1 + child field cost, multiplied by expected count
(args.first ?? 20) * childComplexity + 1,
},
},
},
};
For MCP tool servers that accept natural-language-constructed GraphQL queries (e.g., an LLM generating queries on behalf of users), complexity limits are a critical safety valve. Without them, a confused or adversarial agent can construct a query that saturates your database in a single operation. Set the budget conservatively — 200 to 500 points covers most legitimate queries. Log rejected queries to tune the limit over time.
Automatic Persisted Queries (APQ) — cache config and client flow
APQ reduces HTTP request payload size by replacing the full query string with a SHA-256 hash. On first use, the client sends only the hash; if the server doesn't recognise it, it returns a specific error and the client retries with the full query and hash together. On subsequent requests, only the hash is sent. This matters for MCP transports where the same tool schema produces the same query repeatedly.
import { ApolloServer } from '@apollo/server';
import { InMemoryLRUCache } from '@apollo/utils.keyvaluecache';
import { KeyValueCache } from '@apollo/utils.keyvaluecache';
// Option 1: In-memory LRU cache (single-instance deployments)
const server = new ApolloServer({
typeDefs,
resolvers,
persistedQueries: {
// InMemoryLRUCache holds up to 30MB of persisted queries by default
cache: new InMemoryLRUCache({ maxSize: 30_000_000 }),
// Reject queries not in the cache (safelisting mode — only allow pre-registered queries)
// Set to true only if you pre-register all queries at deploy time
// false (default) = accept any valid query, cache after first execution
// ttl: 300, // cache TTL in seconds; undefined = no expiry
},
});
// APQ client flow (for reference when writing MCP tool clients):
// 1. Client computes sha256(queryString), sends: { extensions: { persistedQuery: { version: 1, sha256Hash: '...' } } }
// 2. Server unknown hash → returns: { errors: [{ message: 'PersistedQueryNotFound', extensions: { code: 'PERSISTED_QUERY_NOT_FOUND' } }] }
// 3. Client retries with full query + hash: { query: '...', extensions: { persistedQuery: { version: 1, sha256Hash: '...' } } }
// 4. Server stores query in cache, executes, returns result
// 5. Subsequent requests use hash only — server hits cache
// Option 2: Redis cache (multi-instance deployments)
// npm install @apollo/utils.keyvaluecache redis
import { createClient } from 'redis';
class RedisCache implements KeyValueCache {
constructor(private client: ReturnType<typeof createClient>) {}
async get(key: string) {
return (await this.client.get(key)) ?? undefined;
}
async set(key: string, value: string, options?: { ttl?: number }) {
if (options?.ttl) {
await this.client.setEx(key, options.ttl, value);
} else {
await this.client.set(key, value);
}
}
async delete(key: string) {
await this.client.del(key);
}
}
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
const serverWithRedis = new ApolloServer({
typeDefs,
resolvers,
persistedQueries: { cache: new RedisCache(redisClient), ttl: 86_400 },
});
If you set persistedQueries: false, the server completely disables APQ and rejects hash-only requests with an error. This breaks any client that assumes APQ is supported. If you're unsure whether clients use APQ, leave the default or set an in-memory cache — the overhead is minimal. For safelisting (only allow pre-registered queries), set the cache and at deploy time pre-populate it with all expected query hashes.
Subscription transport — SSE vs WebSocket in MCP servers
Apollo Server v4's standalone server supports subscriptions over WebSocket via graphql-ws. However, many MCP transport layers are HTTP-only (stdio, HTTP streaming). Apollo also supports Server-Sent Events (SSE) via @apollo/server-plugin-subscription-callback or through a separate HTTP endpoint — SSE subscriptions require no WebSocket upgrade and work through HTTP proxies and load balancers that Apollo Server's HTTP transport supports.
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import express from 'express';
import http from 'http';
const schema = makeExecutableSchema({ typeDefs, resolvers });
const app = express();
const httpServer = http.createServer(app);
// WebSocket server for subscriptions
const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' });
const wsServerCleanup = useServer(
{
schema,
// Context for subscriptions — access auth from connectionParams
context: async (ctx) => {
const token = ctx.connectionParams?.authorization;
return { viewerId: token ? verifyToken(token) : null };
},
// Validate connection before subscribing
onConnect: async (ctx) => {
if (!ctx.connectionParams?.authorization) {
throw new Error('Unauthorized: missing authorization in connectionParams');
}
return true;
},
},
wsServer
);
const server = new ApolloServer({
schema,
plugins: [
// Graceful shutdown for WebSocket server
{
async serverWillStart() {
return {
async drainServer() {
await wsServerCleanup.dispose();
},
};
},
},
],
});
await server.start();
app.use('/graphql', express.json(), expressMiddleware(server, {
context: async ({ req }) => ({ viewerId: extractViewerId(req) }),
}));
// Health check endpoint (no auth required)
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
httpServer.listen(4000, () => {
console.log('Apollo Server listening on http://localhost:4000/graphql');
console.log('GraphQL subscriptions available at ws://localhost:4000/graphql');
});
For MCP servers that only need subscriptions within a single tool invocation (short-lived), consider polling queries with a fixed first argument instead of WebSocket subscriptions — fewer moving parts, no connection state to manage, and cleaner semantics for tool-call/response cycles.
Health probe — Apollo Server reachability from an MCP server
Apollo Server v4 exposes a /.well-known/apollo/server-health endpoint that returns { status: "pass" } when the server is ready. This is distinct from the GraphQL execution endpoint and does not require a valid query. Alternatively, introspect with a minimal { __typename } query to verify the execution pipeline is functional.
async function checkApolloHealth(graphqlUrl: string): Promise<{
healthy: boolean;
latencyMs?: number;
error?: string;
}> {
const start = Date.now();
try {
// Minimal introspection query — always valid on any GraphQL schema
const response = await fetch(graphqlUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: '{ __typename }' }),
signal: AbortSignal.timeout(5_000), // 5s timeout
});
if (!response.ok) {
return { healthy: false, error: `HTTP ${response.status}` };
}
const body = await response.json();
if (body.errors) {
return { healthy: false, error: body.errors[0]?.message };
}
return { healthy: true, latencyMs: Date.now() - start };
} catch (err) {
return { healthy: false, error: String(err) };
}
}
// Startup health gate
async function startMcpServer() {
const health = await checkApolloHealth(process.env.GRAPHQL_URL!);
if (!health.healthy) {
console.error('Apollo Server unreachable at startup:', health.error);
process.exit(1);
}
console.log(\`Apollo Server reachable (\${health.latencyMs}ms). Starting MCP server.\`);
}
AliveMCP monitors GraphQL endpoints by sending the { __typename } probe on a 60-second interval and alerting when the response changes from {"data":{"__typename":"Query"}} — catching schema changes, authentication failures, and cold-start latency spikes before your users encounter them in agent tool calls.
Related guides
- MCP Tools for Hasura — JWT claims namespace, allow lists, remote schema auth
- MCP Tools for GraphQL Yoga — plugin ordering, SSE subscriptions, CORS config
- MCP Tools for Pothos — scope auth, complexity plugin, Prisma integration
- MCP Tools for Strawberry — async context, per-request DataLoader, permission classes
- GraphQL API MCP server patterns overview
- MCP server authentication overview