GraphQL API · 2026-07-25 · GraphQL arc
MCP Tools for GraphQL APIs: DataLoader Isolation, Authorization Timing, and Schema Safety Boundaries
Five GraphQL frameworks — Apollo Server, GraphQL Yoga, Strawberry, Pothos, and GraphQL Nexus — all expose the same wire protocol while making completely different decisions about how data loaders are scoped, where authorization runs relative to resolver execution, and what query-safety mechanisms are available out of the box. These decisions determine both how your MCP server must be structured and what fails silently when that structure is wrong. DataLoader instances must be created per-request in the context factory, never at module level or as a singleton — across all five systems, a module-level DataLoader caches results from request A and serves stale or wrong-user data to request B, leaking data across concurrent MCP tool invocations; in Apollo Server v4 the context function is called once per HTTP request; in Strawberry Python the context_getter is called once per ASGI request; a DataLoader created at either of those call sites lives only for the duration of the request, and its cache is garbage-collected after the resolver chain resolves; the batch function in every system must return an array (or list) of the same length and in the same order as the input keys — a plain unsorted database result array will silently serve the wrong record when keys appear in a different order from the database return. Authorization execution timing is not uniform across the five frameworks — Pothos scope-auth and nexus-shield both run as pre-resolver guards that block execution before the database is touched, while Nexus's built-in fieldAuthorizePlugin runs the authorize function after the resolver has already fetched data from the database, meaning a user who fails authorization has already caused a database read; GraphQL Yoga's onContextBuilding lifecycle hook is the correct place for JWT verification across all Yoga plugins, and auth plugins must appear before logging or telemetry plugins in the plugins array because Yoga processes array entries sequentially through pre-execution hooks; Strawberry's BasePermission.has_permission() is synchronous — defining it as async def without Strawberry's experimental async permission support causes Python to call it as a regular function, which returns a coroutine object rather than awaiting the boolean result; a coroutine object is always truthy, so every permission check passes regardless of what the async function actually evaluates. Schema safety boundaries differ in what they protect at the query level versus the field level — Apollo Server's graphql-query-complexity with fieldExtensionsEstimator lets you assign per-list cost multipliers that scale with the first/limit argument, making LLM-generated queries with inadvertently large paginations reject before execution; Pothos's @pothos/plugin-complexity has a defaultListMultiplier but flat cost by default — you must override with a per-field complexity function that reads the argument; Apollo's Automatic Persisted Queries require an explicit persistedQueries: { cache } configuration or hash-only requests fail with PersistedQueryNotFound; Nexus's t.model from nexus-plugin-prisma generates GraphQL fields for every Prisma model column including sensitive ones like passwordHash — the type definition omits the field from the schema but the resolver still fetches it unless you use an explicit Prisma select. This post covers all three patterns with working code for each system, a composite health probe comparison table, a cross-framework failure modes reference, and a platform selection guide for eight MCP server GraphQL use cases.
TL;DR
Five frameworks, three patterns. (1) DataLoader isolation: Apollo Server — create new DataLoader(batchFn) inside the context factory, not at module scope; batch function returns array in same order as keys (use a Map); prime the cache when a parent resolver already has the data; GraphQL Yoga — create DataLoader inside the Yoga context function or pass via dependency injection in onContextBuilding; Strawberry — create DataLoader(load_fn=batch_fn) inside context_getter, never at module level; batch function returns list in same order as keys (use a dict indexed by key); return None for missing keys, not a shorter list. (2) Authorization execution timing: Pothos — @pothos/plugin-scope-auth runs pre-resolver; ForbiddenError returns HTTP 200 with data.field: null + errors[].extensions.code === "FORBIDDEN" — MCP clients must check the errors array, not HTTP status; Nexus — fieldAuthorizePlugin() runs post-resolver; use nexus-shield with rule() for pre-resolver auth; nexus-shield cache: 'contextual' memoizes per-context, 'strict' per-parent+args; Yoga — JWT verification in onContextBuilding hook, auth plugin first in plugins array; Strawberry — do async auth in context_getter, not in has_permission; async def has_permission without async support returns a truthy coroutine object (all checks pass silently). (3) Schema safety boundaries: Apollo Server — graphql-query-complexity with fieldExtensionsEstimator() + per-list complexity function; APQ requires persistedQueries: { cache: new InMemoryLRUCache() }; Pothos — @pothos/plugin-complexity with defaultListMultiplier: 10 and per-field complexity functions that read args.first; Nexus — t.model exposes all Prisma columns; use explicit select in resolver to avoid fetching sensitive fields; shouldExitAfterGenerateArtifacts: process.env.NODE_ENV !== 'production' prevents missing artifact startup failures.
Pattern 1: DataLoader Isolation — Per-Request vs Module-Level Cache Semantics
DataLoader's design involves two orthogonal mechanisms that are easy to conflate: batching (coalescing multiple load(key) calls within one event loop tick into a single batch function call) and caching (memoizing the result of each key so that a second load(key) within the same instance hits memory rather than the batch function). The batching benefit holds at any scope — module-level or per-request. The caching benefit is what makes per-request scoping essential for MCP servers handling concurrent users: a module-level DataLoader caches user A's record under their ID and serves that cached record to user B if they request the same ID within the DataLoader's lifetime (which, at module scope, is the life of the server process).
Apollo Server (JavaScript): Per-Request Context Factory
Apollo Server v4's context function is called once per HTTP request. Creating a new DataLoader inside this function gives it request-scoped lifetime — when the request finishes, the context object is eligible for garbage collection and takes the DataLoader's internal cache with it.
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import DataLoader from 'dataloader';
// Batch function contract: receives array of keys, returns Promise resolving
// to array of values IN THE SAME ORDER and SAME LENGTH as keys.
// Never return a plain unsorted database result — re-order with a Map.
async function batchUsers(ids: readonly string[]) {
const rows = await db.query('SELECT * FROM users WHERE id = ANY($1)', [ids]);
const byId = new Map(rows.map((r: { id: string }) => [r.id, r]));
return ids.map(id => byId.get(id) ?? new Error(`User ${id} not found`));
}
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => {
// Fresh DataLoader every request — cache lifetime = request lifetime
return {
userLoader: new DataLoader(batchUsers),
viewerId: extractViewerId(req.headers.authorization),
};
},
});
// In resolvers, always use context.userLoader — never a module-level loader
const resolvers = {
Post: {
author: (_post: { authorId: string }, _: unknown, ctx: { userLoader: DataLoader<string, unknown> }) =>
ctx.userLoader.load(_post.authorId),
},
};
Priming is a technique for avoiding redundant loads when a parent resolver already has the object a child will load. Call loader.prime(key, value) immediately after fetching a list of parents — each child resolver's load(parentId) call will hit the primed cache entry instead of issuing a batch function call. This is especially valuable in MCP tools where a single tool invocation traverses a graph of related objects.
// Prime the cache after fetching a list of users
const users = await db.query('SELECT * FROM users WHERE org_id = $1', [orgId]);
users.forEach(u => ctx.userLoader.prime(u.id, u)); // future load(u.id) hits cache
return users; // resolver returns the list; child resolvers will hit the cache
GraphQL Yoga (JavaScript): Context Function and Plugin Integration
Yoga's context is built via the context function passed to createYoga(), which is called once per request — the same contract as Apollo Server's context factory. DataLoaders created here are per-request. Yoga also exposes an onContextBuilding plugin hook that fires after the base context is constructed, which is the correct integration point for auth plugins that need to set a viewer object that DataLoader batch functions can reference for row-level access checks.
import { createYoga, createSchema } from 'graphql-yoga';
import DataLoader from 'dataloader';
const yoga = createYoga({
schema: createSchema({ typeDefs, resolvers }),
context: async ({ request }) => {
const viewerId = await verifyJwt(request.headers.get('authorization'));
return {
viewerId,
// Per-request DataLoader — cache scoped to this request
userLoader: new DataLoader<string, User>(async (ids) => {
const rows = await db.users.findMany({ where: { id: { in: [...ids] } } });
const byId = new Map(rows.map(u => [u.id, u]));
return ids.map(id => byId.get(id) ?? new Error(`Not found: ${id}`));
}),
};
},
});
Strawberry (Python): context_getter and the Batch Function Contract
Strawberry's context_getter is an async function called once per ASGI request — exactly analogous to Apollo's context factory. The Python DataLoader (strawberry.dataloader.DataLoader) has the same batch function contract as the JavaScript version: receive a list of keys, return a list of the same length in the same order. The Python specifics: return None for missing keys (not raising an exception, not omitting the index), and return a list of lists when one key maps to multiple results (e.g., posts-by-author).
from strawberry.dataloader import DataLoader
from fastapi import Request
from typing import List, Optional
# WRONG: module-level DataLoader — cache shared across all concurrent requests
# shared_loader = DataLoader(load_fn=load_users) # don't do this
async def load_users(keys: List[str]) -> List[Optional[dict]]:
async with db_pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, name, email FROM users WHERE id = ANY($1::text[])", keys
)
by_id = {row["id"]: dict(row) for row in rows}
# Must return list in same order as keys, same length
# Use None for missing keys (not a shorter list, not a raised exception)
return [by_id.get(k) for k in keys]
async def get_context(request: Request):
return {
# New DataLoader per request — cache isolated to this request
"user_loader": DataLoader(load_fn=load_users),
"viewer_id": extract_viewer_id(request),
}
Pattern 2: Authorization Execution Timing — Pre-Resolver vs Post-Resolver
Authorization in GraphQL can run at three points in the execution lifecycle: during context building (before any resolver runs), as a pre-resolver guard (before the field's resolver executes), or as a post-resolver check (after the resolver has already fetched data). For MCP servers, the distinction between pre- and post-resolver authorization has real database impact — a post-resolver check means the database was queried, the result was materialized, and only then was the user's access evaluated. Each framework in this arc takes a different default position on this axis.
Pothos (JavaScript): Pre-Resolver Scope Auth
Pothos's @pothos/plugin-scope-auth is a pre-resolver authorization system. The authScopes function is called when a field with scope requirements is about to be resolved, and if the scope evaluates to false, a ForbiddenError is thrown before the resolver function runs. The critical GraphQL behavior: the error surfaces as HTTP 200 with the field set to null and an entry in the errors array. MCP tool clients that only check response.ok or HTTP status code will silently receive null data and consider the call successful.
import SchemaBuilder from '@pothos/core';
import ScopeAuthPlugin from '@pothos/plugin-scope-auth';
const builder = new SchemaBuilder<{
Context: { viewer: { id: string; role: string } | null };
AuthScopes: { isAuthenticated: boolean; isAdmin: boolean };
}>({
plugins: [ScopeAuthPlugin],
scopeAuth: {
authScopes: async (ctx) => ({
isAuthenticated: !!ctx.viewer,
isAdmin: ctx.viewer?.role === 'admin',
}),
},
});
builder.queryField('adminReport', (t) =>
t.field({
type: 'Report',
// Pre-resolver: if isAdmin is false, resolver never runs — no DB query
authScopes: { isAdmin: true },
resolve: (_root, _args, ctx) => generateAdminReport(ctx),
})
);
// MCP client: HTTP 200 even on auth failure — check errors array, not HTTP status
const body = await response.json();
const forbidden = body.errors?.find(
(e: { extensions?: { code?: string } }) => e.extensions?.code === 'FORBIDDEN'
);
if (forbidden) throw new Error(`Authorization denied: ${forbidden.message}`);
GraphQL Nexus (JavaScript): Post-Resolver fieldAuthorizePlugin vs Pre-Resolver nexus-shield
Nexus's built-in fieldAuthorizePlugin() is post-resolver: the authorize function receives the resolved value as its first argument, which means the resolver has already executed and the database has already been queried by the time authorization is checked. This is useful for object-level authorization (can the viewer access this specific post?) but is the wrong tool for request-level authorization (should this query execute at all?). For pre-resolver authorization in Nexus, use nexus-shield.
// WRONG for pre-resolver auth — resolver already executed when authorize() runs
import { makeSchema, fieldAuthorizePlugin } from 'nexus';
const schema = makeSchema({
types: [queryType({
definition(t) {
t.field('secretData', {
type: 'SecretData',
// authorize runs AFTER resolve() fetches from DB — data already loaded
authorize: (_root, _args, ctx) => !!ctx.viewer,
resolve: (_root, _args, ctx) =>
ctx.prisma.secretData.findMany(), // runs before authorize check!
});
},
})],
plugins: [fieldAuthorizePlugin()],
});
// RIGHT: nexus-shield for pre-resolver authorization
import { nexusShield, rule } from 'nexus-shield';
const isAuthenticated = rule({ cache: 'contextual' })(
// 'contextual' caches per-context object — one evaluation per request
async (_root, _args, ctx) => !!ctx.viewer
);
const isPostAuthor = rule({ cache: 'strict' })(
// 'strict' caches per unique parent+args combination
async (_root, args, ctx) => {
if (!ctx.viewer) return false;
const post = await ctx.prisma.post.findUnique({
where: { id: args.id },
select: { authorId: true }, // minimal fetch for auth check
});
return post?.authorId === ctx.viewer.id;
}
);
const schemaWithShield = makeSchema({
types: [/* ... */],
plugins: [
nexusShield({
rules: {
Query: {
secretData: isAuthenticated, // blocks resolver before DB hit
editPost: isAuthenticated,
},
},
options: { fallbackRule: allow },
}),
],
});
GraphQL Yoga (JavaScript): onContextBuilding for Auth Plugins
Yoga processes plugins through each lifecycle hook in array order. The onContextBuilding hook fires after the request is parsed but before any resolver runs — it is the correct place for JWT verification. Auth plugins must appear early in the plugins array: a logging plugin that wraps the request before the auth plugin might consume the request body, and telemetry plugins that measure resolver duration should come after auth plugins so they capture the execution time of authorized operations.
import { createYoga, Plugin } from 'graphql-yoga';
import { useJWT } from '@graphql-yoga/plugin-jwt';
const authPlugin: Plugin = {
// onContextBuilding: request parsed, not yet executing
onContextBuilding({ context, extendContext }) {
const authHeader = context.request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) return;
try {
const payload = verifyJwt(authHeader.slice(7));
extendContext({ viewer: { id: payload.sub, role: payload.role } });
} catch {
throw new Error('Invalid or expired token');
}
},
};
const yoga = createYoga({
schema,
plugins: [
authPlugin, // 1. Auth first — sets viewer in context
useMaskedErrors(), // 2. Error masking after auth (hides stack traces)
observabilityPlugin, // 3. Logging/metrics last — sees final authorized response
],
});
Strawberry (Python): The Async has_permission Coroutine Trap
Strawberry's BasePermission class has a has_permission(self, source, info, **kwargs) method with a synchronous signature. Strawberry calls this method as a regular function. If you write it as async def has_permission(...) without Strawberry's experimental async permission support, Python returns a coroutine object when the function is invoked — it does not raise an error, does not await the coroutine. A coroutine object in Python is always truthy, so the permission check always passes. This is a silent failure with no error message and no traceback.
import strawberry
from strawberry.permission import BasePermission
from strawberry.types import Info
from typing import Any
# WRONG: async def inside sync BasePermission — silently passes all checks
class IsAuthenticatedBroken(BasePermission):
message = "Not authenticated"
async def has_permission(self, source: Any, info: Info, **kwargs: Any) -> bool:
token = info.context["request"].headers.get("authorization", "").removeprefix("Bearer ")
result = await verify_token_async(token) # never awaited — coroutine object is truthy!
return result is not None
# RIGHT: do async auth in context_getter, use sync has_permission to read the result
class IsAuthenticated(BasePermission):
message = "Not authenticated"
def has_permission(self, source: Any, info: Info, **kwargs: Any) -> bool:
# viewer_id was resolved asynchronously in context_getter before resolvers run
return info.context.get("viewer_id") is not None
# In context_getter — called once per ASGI request, async context is available here
async def get_context(request: Request):
token = request.headers.get("authorization", "").removeprefix("Bearer ")
viewer_id = None
if token:
try:
payload = await verify_token_async(token) # properly awaited here
viewer_id = payload.get("sub")
except Exception:
pass
return {"viewer_id": viewer_id, "db_pool": request.app.state.db_pool}
Pattern 3: Schema Safety Boundaries — Complexity, Field Exposure, and APQ
MCP servers that expose GraphQL APIs face a class of security problems that REST APIs do not: a single valid query can touch arbitrary amounts of data depending on its structure. An LLM constructing queries on behalf of users has no intuition for cost — it may generate { users(first: 10000) { posts(first: 500) { comments(first: 100) { ... } } } } because that query looks "complete" from a reasoning perspective, not because it intends to saturate the database. Schema safety in GraphQL requires defending at three layers: query depth and complexity before execution, field-level access control during execution, and query safelisting for repeated tool-call patterns.
Apollo Server (JavaScript): graphql-query-complexity with Field Extension Estimators
Depth limiting alone is insufficient — a query that selects 10,000 sibling fields at depth 1 passes any depth limit while causing the same database fan-out as a deep nested query. Query complexity uses cost budgets: each field has a cost, list fields compound that cost by their expected cardinality, and the total is checked before execution begins.
import { ApolloServer } from '@apollo/server';
import { getComplexity, fieldExtensionsEstimator, simpleEstimator } from 'graphql-query-complexity';
import depthLimit from 'graphql-depth-limit';
// Attach complexity cost to list fields via resolver extensions
const resolvers = {
Query: {
users: {
resolve: (_: unknown, args: { first?: number }, ctx: Context) =>
ctx.prisma.user.findMany({ take: args.first ?? 20 }),
extensions: {
// fieldExtensionsEstimator reads this from the field definition
complexity: ({ args, childComplexity }: { args: { first?: number }; childComplexity: number }) =>
(args.first ?? 20) * childComplexity + 1,
},
},
},
};
const complexityPlugin = {
requestDidStart: () => ({
didResolveOperation({ request, document }: { request: { variables?: Record<string, unknown> }; document: DocumentNode }) {
const complexity = getComplexity({
schema,
query: document,
variables: request.variables,
estimators: [
fieldExtensionsEstimator(), // reads cost from resolver.extensions.complexity
simpleEstimator({ defaultComplexity: 1 }), // fallback: 1 per field
],
});
if (complexity > 500) {
throw new Error(
`Query complexity ${complexity} exceeds budget of 500. ` +
`Paginate with smaller first/limit values (e.g., first: 20 instead of first: 1000).`
);
}
},
}),
};
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(8)],
plugins: [complexityPlugin],
});
Automatic Persisted Queries (APQ) complement complexity limits for MCP servers where the same tool schema produces the same query repeatedly. APQ reduces HTTP payload by replacing the full query string with a SHA-256 hash after the first successful execution. The server requires a configured cache — without it, hash-only requests return PersistedQueryNotFound, breaking any client that assumes APQ is supported.
import { InMemoryLRUCache } from '@apollo/utils.keyvaluecache';
const server = new ApolloServer({
typeDefs, resolvers,
persistedQueries: {
// Required: without this, Apollo accepts full queries but rejects hash-only APQ requests
cache: new InMemoryLRUCache({ maxSize: 30_000_000 }),
// ttl: 86_400, // optional: expire entries after 24 hours
},
});
Pothos (JavaScript): Complexity Plugin with List Multipliers
Pothos's @pothos/plugin-complexity integrates complexity limits at the schema-builder level. The default behavior assigns equal cost to every field regardless of whether it resolves a scalar or a list of 1000 objects. The defaultListMultiplier scales list field costs, but for fields whose cardinality depends on a pagination argument (first, limit), you need per-field complexity functions that read the argument value.
import ComplexityPlugin from '@pothos/plugin-complexity';
const builder = new SchemaBuilder<{ Context: AppContext }>({
plugins: [ScopeAuthPlugin, ComplexityPlugin],
complexity: {
limit: { complexity: 500, depth: 8, breadth: 15 },
defaultComplexity: 1,
// Multiplier for list fields without an explicit per-field function
defaultListMultiplier: 10,
},
});
builder.queryField('users', (t) =>
t.prismaField({
type: ['User'],
args: { first: t.arg.int({ defaultValue: 20 }) },
// Per-field complexity reads the first argument to scale cost
// Without this, users(first: 1000) costs the same as users(first: 1)
complexity: (args, childComplexity) =>
(args.first ?? 20) * childComplexity + 1,
resolve: (query, _root, args, ctx) =>
ctx.prisma.user.findMany({ ...query, take: args.first ?? 20 }),
})
);
GraphQL Nexus (JavaScript): Field Exposure and t.model Sensitive Columns
Nexus's nexus-plugin-prisma (t.model) generates GraphQL field definitions from your Prisma schema automatically. Every Prisma model column becomes available as a t.model.* field. Omitting a column from the Nexus type definition removes it from the GraphQL schema — but does not prevent Prisma from fetching it when the resolver executes a findUnique() without an explicit select. The sensitive data is fetched, held in memory, and only excluded from the GraphQL response. For truly safe field exclusion, use Prisma's select in the resolver.
// RISKY: t.model fields are not exposed in GraphQL schema,
// but Prisma still fetches them when resolver runs without select
import { objectType, queryType, nonNull, idArg } from 'nexus';
const User = objectType({
name: 'User',
definition(t) {
t.model.id();
t.model.name();
// t.model.passwordHash() — omitted from schema
// But resolver below still fetches passwordHash from DB
},
});
const Query = queryType({
definition(t) {
t.field('user', {
type: 'User',
args: { id: nonNull(idArg()) },
authorize: (_root, _args, ctx) => !!ctx.viewer,
resolve: (_root, args, ctx) =>
// RISKY: no select — fetches all columns including passwordHash
ctx.prisma.user.findUnique({ where: { id: args.id } }),
});
},
});
// SAFE: explicit Prisma select prevents sensitive columns from ever being fetched
const SafeQuery = queryType({
definition(t) {
t.field('user', {
type: 'User',
args: { id: nonNull(idArg()) },
authorize: (_root, _args, ctx) => !!ctx.viewer,
resolve: (_root, args, ctx) =>
ctx.prisma.user.findUnique({
where: { id: args.id },
select: { id: true, name: true }, // passwordHash never fetched, never in memory
}),
});
},
});
Nexus also writes TypeScript type artifacts (nexus-typegen.ts, schema.graphql) to disk during development. Production Docker containers must pre-build these files in the build stage — if they're missing at startup, Nexus either tries to write them (fails without filesystem write permission) or throws an error depending on shouldExitAfterGenerateArtifacts.
import { makeSchema } from 'nexus';
import path from 'path';
export const schema = makeSchema({
types: [/* all types */],
outputs: {
typegen: path.join(__dirname, '../nexus-typegen.ts'),
schema: path.join(__dirname, '../schema.graphql'),
},
// Skip artifact generation in production — files must exist from build stage
shouldExitAfterGenerateArtifacts: process.env.NODE_ENV === 'development',
});
Composite Health Probe Comparison
All five frameworks respond to the { __typename } introspection meta-field, which is always valid, requires no authentication, and exercises the full parsing, validation, and execution pipeline without touching application data. The probe differs in HTTP method and response shape by framework:
| Framework | Method | Endpoint | Expected body | Additional probe |
|---|---|---|---|---|
| Apollo Server | POST | /graphql |
{"data":{"__typename":"Query"}} |
GET /.well-known/apollo/server-health → {"status":"pass"} |
| GraphQL Yoga | GET | /graphql?query={__typename} |
{"data":{"__typename":"Query"}} |
Accept: application/json header required for JSON response (otherwise GraphiQL HTML) |
| Strawberry | POST | /graphql |
{"data":{"__typename":"Query"}} |
Use httpx.AsyncClient(timeout=5.0) — requests library blocks asyncio event loop |
| Pothos | POST | /graphql |
{"data":{"__typename":"Query"}} |
Complexity limit errors appear in errors[] even on liveness probes if the probe query is above budget |
| GraphQL Nexus | POST | /graphql |
{"data":{"__typename":"Query"}} |
Schema drift probe: introspect runtime schema, compare against committed schema.graphql |
AliveMCP monitors GraphQL endpoints with the lightweight { __typename } probe on a 60-second interval and separately tracks an authenticated deep probe when a monitoring token is configured — the deep probe catches authorization regressions (scope changes, plugin ordering bugs) that the structural probe misses. Both probes are included in the Author and Team tier dashboards.
Failure Modes Cross-Reference
The following failure modes appear repeatedly across these five frameworks and cause silent data issues, silent authorization bypasses, or silent query abuse — none of them produce obvious errors at the point of misconfiguration.
| # | Failure | Affects | Root cause | Fix |
|---|---|---|---|---|
| 1 | Module-level DataLoader leaks cross-user data | Apollo Server, GraphQL Yoga, Strawberry, Pothos, Nexus | DataLoader created at module scope; cache persists across requests; user A's rows served to user B | Create DataLoader inside context factory / context_getter, once per request |
| 2 | Batch function returns unsorted array | All JS/Python systems using DataLoader | DB result order differs from key order; DataLoader maps index-to-index; wrong record served to resolver | Index DB results by key with a Map or dict; return ids.map(id => byId.get(id)) |
| 3 | Depth limit without complexity limit allows wide queries | Apollo Server, Pothos, Nexus | graphql-depth-limit blocks nesting but allows users(first: 10000) at depth 1; same DB fan-out |
Add graphql-query-complexity (Apollo) or @pothos/plugin-complexity with list multipliers |
| 4 | APQ without server cache | Apollo Server | Apollo accepts full queries but returns PersistedQueryNotFound for hash-only requests; client sees error |
Configure persistedQueries: { cache: new InMemoryLRUCache() } |
| 5 | Pothos scope auth returns HTTP 200 on auth failure | Pothos | GraphQL convention: ForbiddenError → HTTP 200, field null, errors array; MCP client checks res.ok only |
Check body.errors?.find(e => e.extensions?.code === 'FORBIDDEN') before accessing data |
| 6 | Nexus fieldAuthorizePlugin runs after resolver | GraphQL Nexus | authorize() function receives resolved value — DB already queried before authorization check |
Use nexus-shield with rule() for pre-resolver authorization |
| 7 | Nexus t.model exposes sensitive Prisma fields in memory | GraphQL Nexus | Omitting t.model.passwordHash() from schema removes it from GraphQL response but not from Prisma fetch |
Use Prisma select in resolver to prevent sensitive columns from being fetched |
| 8 | Strawberry async has_permission passes all checks | Strawberry | async def has_permission → Strawberry calls it as sync → coroutine object (truthy) → always passes |
Do async auth in context_getter; use sync has_permission to read pre-resolved viewer |
| 9 | Yoga + Express double CORS headers | GraphQL Yoga | Yoga cors: { origin } + Express cors() middleware both set Access-Control-Allow-Origin; browsers reject duplicate header |
Set CORS in exactly one layer — Yoga or the outer framework, not both |
| 10 | Nexus type artifacts missing in production container | GraphQL Nexus | makeSchema() writes nexus-typegen.ts and schema.graphql at startup; Docker image built without pre-generating them |
Run ts-node src/schema.ts in Docker build stage; set shouldExitAfterGenerateArtifacts: false in production |
Platform Selection Guide
Choosing a GraphQL framework for an MCP server backend is primarily about matching the authorization model, language ecosystem, and query-safety tooling to the use case. The following table covers eight common MCP server GraphQL scenarios.
| MCP server use case | Recommended framework | Key reason |
|---|---|---|
| High-traffic read-heavy tools with complex nested data | Pothos + Prisma | t.relation() batches relation loads via Prisma include — no separate DataLoader needed for relations; built-in complexity plugin prevents nested query abuse |
| Type-safe CRUD tools with Prisma ORM | GraphQL Nexus + nexus-plugin-prisma | t.model generates CRUD fields from Prisma schema automatically; strongly typed resolvers via generated nexus-typegen.ts; pair with nexus-shield for pre-resolver auth |
| MCP tools wrapping existing third-party GraphQL APIs | GraphQL Yoga | Framework-agnostic (Node, Deno, Bun, Cloudflare Workers); SSE subscriptions built-in; zero-dependency schema stitching via @graphql-tools/stitch |
| Python MCP servers querying GraphQL backends | Strawberry | Native Python asyncio; Pydantic-compatible types; async resolvers with asyncpg/databases driver; works with FastAPI and Starlette ASGI stacks |
| Tools with complex per-resource authorization rules | Pothos scope-auth | Declarative per-field and per-type scopes; scope function results cached per-request (no repeated DB checks for same permission in same request); dynamic scope args for resource-level checks |
| MCP tools executing LLM-generated GraphQL queries | Apollo Server | APQ caches repeated LLM-generated queries after first execution; graphql-query-complexity with field extensions rejects expensive LLM-constructed queries before DB hit; safelisting possible via pre-populated APQ cache |
| Federated multi-service MCP architectures | Apollo Server (Federation) | Apollo Federation subgraph protocol; @key directives for entity references; @requires and @provides for cross-service field resolution; Router handles distributed DataLoader batching |
| Serverless or edge-deployed MCP tools | GraphQL Yoga | Web Fetch API compatible (no Node built-ins required); runs on Cloudflare Workers, Vercel Edge, Next.js API routes, and Bun natively; no server startup cost for context initialization |
Related guides
- MCP Tools for Apollo Server — DataLoader batching, query complexity limits, persisted queries
- MCP Tools for GraphQL Yoga — plugin ordering, SSE subscriptions, CORS config
- MCP Tools for Strawberry — async context, per-request DataLoader, permission classes
- MCP Tools for Pothos — scope auth, complexity plugin, Prisma N+1 protection
- MCP Tools for GraphQL Nexus — type-safe schema, fieldAuthorizationPlugin, Prisma Nexus
- MCP Tools for Auth & Identity Providers — verification spectrum, token lifetime, revocation