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