Guide · GraphQL API
MCP Tools for GraphQL Yoga — plugin ordering, SSE subscriptions, CORS config
Three GraphQL Yoga behaviours trip developers building MCP integrations: plugin execution order determines whether your auth plugin sees the request before or after other plugins modify it — Yoga processes plugins sequentially through each lifecycle hook (onRequest, onParse, onValidate, onExecute), and if a logging plugin wraps the request before your auth plugin runs, the auth plugin may see an already-consumed request body; put auth plugins early in the plugins array and telemetry plugins last; SSE subscriptions work out of the box but WebSocket subscriptions require a separate graphql-ws server configuration — Yoga's built-in multipart/SSE subscription transport starts without extra packages, but ws:// connections to the same path will receive an HTTP response and immediately close because Yoga doesn't handle the WebSocket upgrade natively; and combining Yoga's built-in cors option with an outer Express or Fastify CORS middleware produces duplicate Access-Control-Allow-Origin headers, which some browsers reject as invalid — choose one layer for CORS and leave the other unconfigured.
TL;DR
Auth plugin first: plugins: [authPlugin, useMaskedErrors(), useLogger()]. SSE subscriptions need no extra package — use createPubSub() from graphql-yoga. For WebSocket subscriptions add graphql-ws and handle the HTTP server upgrade separately. Set CORS in Yoga only, not in Express: createYoga({ cors: { origin: 'https://your-client.com' } }). Health probe: GET /graphql?query={__typename} — expect HTTP 200.
Plugin ordering — auth, error masking, and observability
Yoga plugins are called in array order for pre-execution hooks and in reverse order for post-execution hooks. Each plugin can intercept the request lifecycle at multiple points: onRequest, onParse, onValidate, onContextBuilding, onExecute, and onResponse. An auth plugin that reads the Authorization header should hook into onContextBuilding — by that phase the request is parsed but execution hasn't started, and the verified identity can be placed in context for resolvers.
import { createYoga, createSchema, Plugin } from 'graphql-yoga';
import { useJWT } from '@graphql-yoga/plugin-jwt';
import { useMaskedErrors } from '@envelop/core';
// Custom auth plugin that runs at context-building time
const authPlugin: Plugin = {
onContextBuilding({ context, extendContext }) {
// context.request is the incoming Request object
const authHeader = context.request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
// Allow unauthenticated (resolvers enforce auth per-field)
return;
}
const token = authHeader.slice(7);
try {
const payload = verifyJwt(token);
extendContext({ viewer: { id: payload.sub, role: payload.role } });
} catch (err) {
// Throw here to reject the entire request before any resolver runs
throw new Error('Invalid token');
}
},
};
// Plugin that blocks unauthenticated execution entirely
const requireAuthPlugin: Plugin = {
onExecute({ args }) {
const viewer = (args.contextValue as { viewer?: { id: string } }).viewer;
if (!viewer) {
throw new Error('Unauthenticated');
}
},
};
const yoga = createYoga({
schema: createSchema({ typeDefs, resolvers }),
plugins: [
// 1. Auth first — sets viewer in context
authPlugin,
// 2. Optionally block unauthenticated execution entirely
// requireAuthPlugin,
// 3. Mask internal errors from clients (hides stack traces, DB errors)
// Uses @envelop/masked-errors — install: npm install @envelop/core
useMaskedErrors(),
// 4. Logging/observability last — sees the final response including masked errors
{
onResponse({ response, serverContext }) {
const ctx = serverContext as { viewer?: { id: string } };
console.log({
viewer: ctx.viewer?.id ?? 'anonymous',
status: response.status,
});
},
},
],
});
// Using the official @graphql-yoga/plugin-jwt for JWT verification
// npm install @graphql-yoga/plugin-jwt jose
import { useJWT } from '@graphql-yoga/plugin-jwt';
const yogaWithJwt = createYoga({
schema,
plugins: [
useJWT({
// JWKS: fetches public keys from URL (for RS256, ES256)
signingKeyProviders: [
createRemoteJwksSigningKeyProvider({
jwksUri: 'https://your-auth-provider/.well-known/jwks.json',
}),
],
// Or symmetric secret (for HS256)
// signingKeyProviders: [createInlineSigningKeyProvider({ signingKey: 'your-secret' })],
// Token location: header (default) or cookie
tokenLookupLocations: [
extractFromHeader({ name: 'authorization', prefix: 'Bearer ' }),
],
// Add verified claims to context
extendContextFieldName: 'jwt',
// reject: false = allow unauthenticated requests (per-field auth)
// reject: true = block all unauthenticated requests at this plugin
reject: {
missingToken: false,
invalidToken: true, // reject malformed/expired tokens
},
}),
],
});
SSE subscriptions — built-in transport vs WebSocket
Yoga ships with multipart streaming and Server-Sent Events (SSE) subscription transport — no extra packages needed. Clients subscribe via a regular HTTP GET or POST request to the GraphQL endpoint with Accept: text/event-stream. This works through HTTP/1.1 proxies, load balancers, and CDNs that block WebSocket upgrades. The trade-off: SSE is unidirectional (server to client) and HTTP/1.1 clients can only maintain 6 concurrent SSE connections per browser origin.
import { createYoga, createSchema, createPubSub } from 'graphql-yoga';
// PubSub is the in-memory message bus for subscriptions
// For multi-instance deployments, replace with a Redis-backed pubsub
const pubSub = createPubSub<{
MESSAGE_ADDED: [{ id: string; text: string; authorId: string }];
USER_STATUS_CHANGED: [{ userId: string; online: boolean }];
}>();
const schema = createSchema({
typeDefs: \`
type Query { _: Boolean }
type Subscription {
messageAdded(channelId: ID!): Message!
userStatusChanged(userId: ID!): UserStatus!
}
type Message { id: ID! text: String! authorId: ID! }
type UserStatus { userId: ID! online: Boolean! }
\`,
resolvers: {
Subscription: {
messageAdded: {
// subscribe() returns an AsyncIterable
subscribe: (_root, { channelId }, context) => {
// Verify subscriber is authorized to watch this channel
if (!isChannelMember(context.viewer?.id, channelId)) {
throw new Error('Not a member of this channel');
}
return pubSub.subscribe('MESSAGE_ADDED');
},
resolve: (payload) => payload,
},
userStatusChanged: {
subscribe: (_root, { userId }) =>
pubSub.subscribe('USER_STATUS_CHANGED'),
resolve: (payload) => payload,
},
},
},
});
// Publish from a mutation or background job
async function publishMessage(message: { id: string; text: string; authorId: string }) {
await saveToDatabase(message);
// Triggers all subscribers — SSE connections receive the event immediately
pubSub.publish('MESSAGE_ADDED', message);
}
const yoga = createYoga({ schema, context: buildContext });
// SSE client usage (from browser or MCP tool):
// const response = await fetch('http://localhost:4000/graphql', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' },
// body: JSON.stringify({ query: 'subscription { messageAdded(channelId: "ch1") { id text } }' }),
// });
// for await (const chunk of response.body!) { /* parse SSE frames */ }
// WebSocket subscriptions require graphql-ws + manual upgrade handling
// npm install graphql-ws ws
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
const httpServer = createServer(yoga);
// WebSocket server on the same port as Yoga's HTTP server
// The 'upgrade' event fires for ws:// connections; Yoga handles HTTP connections
const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' });
useServer({ schema, context: buildWsContext }, wsServer);
httpServer.listen(4000);
// IMPORTANT: Yoga's SSE transport and graphql-ws coexist on the same path
// HTTP clients with Accept: text/event-stream get SSE
// WebSocket upgrade requests go to graphql-ws
// Regular HTTP POST clients get standard GraphQL responses
// The distinction is made by the protocol at the TCP level, not the URL path
CORS configuration — single-layer rule
Yoga has a built-in CORS handler accessed via the cors option. If you also apply a CORS middleware from Express (cors() package) or Fastify (@fastify/cors), both layers set Access-Control-Allow-Origin on every response. When two Access-Control-Allow-Origin headers are present, browsers enforce the stricter interpretation: the header is treated as invalid, and the request fails with a CORS error — the opposite of what both handlers intended.
import { createYoga } from 'graphql-yoga';
import express from 'express';
// OPTION A: Let Yoga handle CORS (recommended when using Yoga middleware)
const yoga = createYoga({
schema,
cors: {
origin: ['https://app.yourproduct.com', 'https://staging.yourproduct.com'],
allowedHeaders: ['Content-Type', 'Authorization'],
methods: ['GET', 'POST', 'OPTIONS'],
credentials: true, // required for cookie-based auth
maxAge: 86_400, // preflight cache: 24 hours
},
});
// Do NOT add cors() middleware from the cors package here
const app = express();
app.use('/graphql', yoga); // Yoga's CORS applies; no Express cors() middleware
// OPTION B: Let Express handle CORS (when you have other routes needing CORS)
import cors from 'cors';
const yogaWithoutCors = createYoga({
schema,
// Do NOT set cors option here
});
const appWithExpressCors = express();
appWithExpressCors.use(cors({
origin: 'https://app.yourproduct.com',
credentials: true,
}));
appWithExpressCors.use('/graphql', yogaWithoutCors);
// OPTION C: Disable Yoga's CORS entirely (for internal services with no browser clients)
const internalYoga = createYoga({
schema,
cors: false, // disables all CORS headers — only safe for server-to-server APIs
});
// Common mistake — both set CORS headers:
// WRONG:
const buggyYoga = createYoga({ schema, cors: { origin: '*' } });
const buggyApp = express();
buggyApp.use(cors({ origin: '*' })); // double CORS headers → browser CORS error
buggyApp.use('/graphql', buggyYoga);
For MCP tool servers that are called server-to-server (not from a browser), CORS is irrelevant — browsers are not in the picture. Set cors: false and save the overhead. Only configure CORS if your MCP server also serves a browser-accessible endpoint (e.g., a GraphQL Playground or a status UI).
Health probe — GraphQL Yoga reachability from an MCP server
Yoga responds to GET /graphql?query={__typename} with a 200 response and {"data":{"__typename":"Query"}}. This works without authentication if your schema allows unauthenticated access to the root __typename meta-field, which all GraphQL servers do. Yoga also responds to GET /graphql with the GraphiQL UI (or a 400 if GraphiQL is disabled), which is a lighter liveness probe but doesn't exercise the execution pipeline.
async function checkYogaHealth(graphqlUrl: string): Promise<{
healthy: boolean;
latencyMs?: number;
error?: string;
}> {
const start = Date.now();
try {
// GET request with query in URL — no request body needed
const url = new URL(graphqlUrl);
url.searchParams.set('query', '{ __typename }');
const response = await fetch(url.toString(), {
method: 'GET',
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) {
return { healthy: false, error: \`HTTP \${response.status}\` };
}
const body = await response.json();
if (body.errors || body.data?.__typename !== 'Query') {
return { healthy: false, error: JSON.stringify(body.errors ?? body) };
}
return { healthy: true, latencyMs: Date.now() - start };
} catch (err) {
return { healthy: false, error: String(err) };
}
}
// Startup check in MCP server
async function startMcpServer() {
const health = await checkYogaHealth(process.env.GRAPHQL_URL!);
if (!health.healthy) {
console.error('GraphQL Yoga unreachable at startup:', health.error);
process.exit(1);
}
console.log(\`GraphQL Yoga reachable (\${health.latencyMs}ms). Starting MCP server.\`);
}
AliveMCP monitors GraphQL Yoga endpoints by sending the { __typename } probe every 60 seconds and detecting schema changes — a resolved __typename: "Query" confirms the execution pipeline is healthy, while missing or unexpected values indicate a schema reload or plugin error that would silently fail tool calls in production.
Related guides
- MCP Tools for Apollo Server — DataLoader batching, query complexity, persisted queries
- MCP Tools for Pothos — scope auth, complexity plugin, Prisma integration
- MCP Tools for Strawberry — async context, per-request DataLoader, permission classes
- MCP Tools for GraphQL Nexus — type-safe schema, Prisma plugin, fieldAuthz
- MCP server GraphQL subscription patterns
- MCP server authentication overview