Guide · MCP GraphQL Integration
MCP server with Apollo Client — querying GraphQL from MCP tool handlers
Apollo Client is the most capable GraphQL client for Node.js: it normalizes query results into a shared cache, deduplicates in-flight requests, and provides a clean interface for queries, mutations, and subscriptions. When you build an MCP server that wraps a GraphQL API, Apollo Client can reduce redundant network calls within a session by caching results across tool invocations. But Apollo's design assumptions — a single browser client for one user — require adaptation when you're running in a multi-session server context. This page covers how to configure Apollo Client for an MCP server: choosing between shared and per-session client instances, tuning the InMemoryCache for MCP workloads, mapping ApolloError to MCP error responses, and monitoring the parts of your stack that Apollo's own error tracking cannot see.
TL;DR
Use a single shared Apollo Client for stateless tools (no user-specific data in cache). Create per-session Apollo Client instances for tools that return user-specific results — shared cache leaks data between sessions. Map ApolloError.graphQLErrors to isError: true MCP responses. Monitor MCP protocol health with AliveMCP — Apollo's error tracking reports tool-level failures but cannot detect server-level outages.
Setting up Apollo Client in a Node.js MCP server
Apollo Client is primarily designed for browser use, but works in Node.js with a different HTTP link configuration. The critical difference from a browser setup is that you cannot use window.fetch — you need to explicitly provide a fetch implementation:
import { ApolloClient, InMemoryCache, HttpLink, from, ApolloError } from "@apollo/client/core";
import { onError } from "@apollo/client/link/error";
import fetch from "cross-fetch";
const errorLink = onError(({ graphQLErrors, networkError, operation }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path }) =>
console.error(`[GraphQL error] Operation: ${operation.operationName} | Message: ${message} | Path: ${path?.join(".")}`)
);
}
if (networkError) {
console.error(`[Network error] Operation: ${operation.operationName} | ${networkError.message}`);
}
});
const httpLink = new HttpLink({
uri: "https://api.example.com/graphql",
headers: { Authorization: `Bearer ${process.env.GRAPHQL_API_KEY}` },
fetch,
});
// Shared client — suitable for public/non-user-specific data only
export const sharedApolloClient = new ApolloClient({
link: from([errorLink, httpLink]),
cache: new InMemoryCache(),
defaultOptions: {
query: {
fetchPolicy: "network-only", // Always fetch fresh data; don't return stale cache
},
},
});
The fetchPolicy: "network-only" default is important for MCP tool handlers. Without it, Apollo returns cached results even when the underlying data has changed — which is usually the right behavior in a browser (fast UI updates) but wrong in an MCP context where the LLM expects current data every time it calls a tool.
Shared client vs per-session client instances
This is the most important architectural decision when using Apollo Client in an MCP server. Get it wrong and you either leak user data between sessions or create unnecessary overhead.
| Pattern | When to use | Risk | Memory profile |
|---|---|---|---|
| Single shared client | Tools only query public or tenant-agnostic data | Cache isolation: user A's results visible to user B if same cache key | Low (one cache) |
| Per-session client | Tools return user-specific results (orders, profile, account data) | Memory leak if sessions are not cleaned up on disconnect | Scales with concurrent sessions |
| No cache (network-only everywhere) | Data changes frequently; caching adds more confusion than benefit | Higher upstream query volume; no deduplication | Minimal |
For per-session clients, tie the client lifecycle to the MCP session lifecycle so clients are garbage collected when sessions end:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const sessionClients = new Map<string, ApolloClient<unknown>>();
function getOrCreateClient(sessionId: string, authToken: string): ApolloClient<unknown> {
const existing = sessionClients.get(sessionId);
if (existing) return existing;
const client = new ApolloClient({
link: from([
errorLink,
new HttpLink({
uri: "https://api.example.com/graphql",
headers: { Authorization: `Bearer ${authToken}` },
fetch,
}),
]),
cache: new InMemoryCache(),
});
sessionClients.set(sessionId, client);
return client;
}
// Clean up on session end to prevent memory leak
server.onSessionClose((sessionId) => {
const client = sessionClients.get(sessionId);
if (client) {
client.stop();
sessionClients.delete(sessionId);
}
});
If your MCP server does not expose session lifecycle hooks, use a WeakMap keyed by the session context object, or implement a TTL-based eviction: if a client has not been used in 30 minutes, clear it.
InMemoryCache behavior in MCP tool handlers
Apollo's InMemoryCache normalizes query results by __typename and id (or your configured keyFields). When two queries return the same User:123 object, the cache stores only one copy and both queries reference it. This is efficient for a browser client rendering multiple views of the same data. In an MCP server, it has a different effect:
- Deduplication benefit: If the LLM calls
get_user("123")three times in one session, the second and third calls return immediately from cache without a network request. This is genuinely useful when the LLM re-reads a record it already fetched during the same reasoning chain. - Staleness risk: If a mutation tool modifies
User:123, the cache still holds the old version. Subsequent reads viaget_user("123")return the pre-mutation state unless you explicitly invalidate the cache entry or refetch.
Handle post-mutation cache invalidation explicitly:
import { gql } from "@apollo/client/core";
server.tool("update_user_plan", "...", { userId: z.string(), plan: z.enum(["free", "author", "team"]) },
async ({ userId, plan }, { sessionId }) => {
const client = getOrCreateClient(sessionId, getAuthToken(sessionId));
const result = await client.mutate({
mutation: gql`
mutation UpdatePlan($userId: ID!, $plan: PlanTier!) {
updateUserPlan(userId: $userId, plan: $plan) { id plan updatedAt }
}
`,
variables: { userId, plan },
// Invalidate the cached User object so next get_user call fetches fresh data
update(cache, { data }) {
cache.evict({ id: cache.identify({ __typename: "User", id: userId }) });
cache.gc();
},
});
return {
content: [{ type: "text", text: JSON.stringify(result.data?.updateUserPlan, null, 2) }],
};
}
);
Mapping ApolloError to MCP error responses
Apollo Client throws an ApolloError when a query fails. An ApolloError has two distinct error sources that require different handling in an MCP context:
import { ApolloError } from "@apollo/client/core";
import { z } from "zod";
async function runQuery<T>(
client: ApolloClient<unknown>,
query: DocumentNode,
variables: Record<string, unknown>
): Promise<{ data: T | null; errorMessage: string | null }> {
try {
const result = await client.query<T>({ query, variables, fetchPolicy: "network-only" });
return { data: result.data, errorMessage: null };
} catch (err) {
if (err instanceof ApolloError) {
if (err.graphQLErrors.length > 0) {
// GraphQL-layer errors: validation failures, resolver errors, auth failures
const message = err.graphQLErrors.map((e) => e.message).join("; ");
return { data: null, errorMessage: `GraphQL error: ${message}` };
}
if (err.networkError) {
// Transport-layer errors: server down, TLS failure, network timeout
return { data: null, errorMessage: `Network error: ${err.networkError.message}` };
}
}
return { data: null, errorMessage: "Unexpected error querying GraphQL API" };
}
}
// Usage in a tool handler
server.tool("get_user", "...", { id: z.string() }, async ({ id }, { sessionId }) => {
const client = getOrCreateClient(sessionId, getAuthToken(sessionId));
const { data, errorMessage } = await runQuery<{ user: User }>(client, GET_USER, { id });
if (errorMessage) {
return { content: [{ type: "text", text: errorMessage }], isError: true };
}
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) }] };
});
Key rule: always return isError: true when a tool cannot fulfill its stated purpose. Never return a success response with an error message embedded in the text — LLMs interpret isError: false as confirmation that the tool call succeeded, regardless of the text content.
Apollo Client with authentication tokens per session
Many GraphQL APIs use per-user authentication tokens that must be passed in the Authorization header. In an MCP server, the authentication token is typically provided during the initialize handshake (via server capabilities metadata) or by a proxy that injects it into session context. Configure Apollo's HTTP link with a dynamic header function so each session uses its own token:
import { setContext } from "@apollo/client/link/context";
function createAuthenticatedClient(getToken: () => string): ApolloClient<unknown> {
const authLink = setContext((_, { headers }) => ({
headers: {
...headers,
Authorization: `Bearer ${getToken()}`,
},
}));
return new ApolloClient({
link: from([errorLink, authLink, new HttpLink({ uri: GRAPHQL_URI, fetch })]),
cache: new InMemoryCache(),
});
}
// Token rotation: if your API returns a refreshed token in response headers,
// capture it via a response link:
import { ApolloLink, Observable } from "@apollo/client/core";
const tokenRefreshLink = new ApolloLink((operation, forward) =>
new Observable((observer) => {
forward(operation).subscribe({
next: (response) => {
const newToken = operation.getContext().response?.headers?.get("X-Refreshed-Token");
if (newToken) storeRefreshedToken(operation.getContext().sessionId, newToken);
observer.next(response);
},
error: observer.error.bind(observer),
complete: observer.complete.bind(observer),
});
})
);
What Apollo Client error tracking cannot see
Apollo Client reports errors from inside your MCP server's tool handlers — GraphQL errors returned by the upstream API, network errors when the API is unreachable. What it cannot see is whether your MCP server itself is reachable and responding to the MCP protocol correctly. These are two separate health signals:
| Failure | Apollo Client sees it? | External MCP monitor sees it? |
|---|---|---|
| GraphQL API returns 500 on a query | Yes — ApolloError.networkError | No (MCP protocol still healthy) |
| GraphQL API returns errors[] on a query | Yes — ApolloError.graphQLErrors | No (MCP protocol still healthy) |
| MCP server process crashes | No (process is gone) | Yes — TCP refused or HTTP 5xx |
| TLS certificate expires | No (connection fails before Apollo) | Yes — TLS handshake failure alert |
| MCP initialize fails after bad deploy | No (initialize runs before tool calls) | Yes — initialize probe fails |
| tools/list returns empty after refactor | No (tools/list is MCP layer) | Yes — empty tools array alert |
External protocol monitoring with AliveMCP covers the MCP-layer failures that Apollo Client is blind to. AliveMCP sends a real initialize request to your server every 60 seconds and verifies that tools/list returns a non-empty array. If your MCP server is listed in Smithery, Glama, or other MCP registries, this external monitoring is what protects your listing from being flagged as unhealthy between registry re-crawl cycles.
For full coverage of a GraphQL-backed MCP server, you need both: Apollo-level error tracking for tool-call failures (your observability layer) and external protocol monitoring for server-level reachability (AliveMCP). Neither alone is sufficient.
Related questions
Should I use Apollo Client or graphql-request for an MCP server?
Use graphql-request if your tools are stateless and you don't need caching or subscriptions. It's lighter, requires less setup, and is easier to reason about in a request/response model. Use Apollo Client when you need the InMemoryCache to avoid redundant queries within a session, or when you need GraphQL subscription support (via Apollo's WebSocket link). Most MCP servers that only need query and mutation support are well-served by graphql-request.
How do I use Apollo Client subscriptions in an MCP server?
Apollo subscriptions require a WebSocket link (@apollo/client/link/ws or the newer graphql-ws library). The challenge in MCP context is that WebSocket connections are stateful and long-lived while MCP sessions may be short-lived. You need to manage subscription cleanup when MCP sessions close. See MCP server GraphQL subscriptions for a full treatment of this pattern.
Can I use Apollo Server (not Apollo Client) to build an MCP server?
Apollo Server is a GraphQL server, not a GraphQL client — it receives GraphQL queries, not sends them. An MCP server that wraps a GraphQL API needs a GraphQL client to talk to the upstream API. Use Apollo Server if you want to expose your own data as both a GraphQL API and an MCP server simultaneously. In that pattern, you'd add an MCP transport alongside your Apollo Server rather than using Apollo Server to call a remote API.
How do I test Apollo Client integration in my MCP server?
Mock the Apollo Client using MockedProvider (from @apollo/client/testing) or set up an in-memory mock server using graphql-tools makeExecutableSchema with mocked resolvers. Write integration tests that call your MCP tool handlers end-to-end with the mocked client, verifying that both successful responses and ApolloErrors are mapped correctly to MCP response shapes. Run tests against a real GraphQL API (staging environment) for pre-deploy verification.
Further reading
- MCP server with GraphQL — wrapping a GraphQL API as MCP tools
- MCP server GraphQL schema design — structuring types for MCP compatibility
- MCP server GraphQL subscriptions — real-time data in MCP tool handlers
- MCP server authentication — token passing and session-scoped credentials
- MCP server session lifecycle — initialize, active, and teardown phases
- MCP server error rate — tool-level error monitoring and SLO budgets
- MCP server observability — structured logs, metrics, and tracing for MCP
- AliveMCP — external MCP protocol monitoring that catches server-level failures Apollo Client can't see