Topic: mcp server graphql security

MCP server GraphQL security — introspection, query depth, and injection

When an MCP tool proxies a GraphQL endpoint, the security boundary moves from "what URL did the LLM call" to "what GraphQL query did the LLM construct." This introduces four attack surfaces that don't exist in REST proxies: introspection leakage (schema disclosure to any caller), unbounded query depth (nested resolver explosions), alias-batching DoS (single request, thousands of resolver calls), and query-string injection (the LLM constructs the query string and can be tricked into injecting malicious fragments). Each has a concrete server-side defense.

Attack 1: Introspection leakage

GraphQL introspection lets any client query the full schema: every type, field, argument, and relationship. This is valuable during development and catastrophic in production if the schema exposes internal models, admin mutations, or fields the frontend never uses.

An MCP server that proxies a GraphQL API with introspection enabled allows the LLM — or any prompt-injected attacker — to discover the schema and then construct queries targeting fields the server author never intended to expose via the MCP tool.

// graphql-yoga — disable introspection in production
import { createYoga } from 'graphql-yoga'

const yoga = createYoga({
  schema,
  maskedErrors: true,
  allowIntrospection: process.env.NODE_ENV !== 'production',
  // In production, returns: {"errors":[{"message":"GraphQL introspection is not allowed"}]}
})

// Apollo Server 4
import { ApolloServer } from '@apollo/server'
const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production',
})

Attack 2: Unbounded query depth

GraphQL schemas commonly have circular or deeply nested type relationships: a User has posts, each Post has author (a User), that User has posts… An LLM that constructs queries can generate arbitrarily deep nesting, causing resolver explosions that exhaust database connections or CPU on the GraphQL server.

import depthLimit from 'graphql-depth-limit'
import { createYoga } from 'graphql-yoga'

const yoga = createYoga({
  schema,
  validationRules: [
    depthLimit(4),  // for MCP-proxied APIs: 3–4 is usually sufficient
    // rejects queries nested more than 4 levels deep
  ],
})

// Manual check if not using a library:
function checkDepth(selectionSet, depth = 0) {
  if (depth > MAX_DEPTH) throw new Error(`Query depth exceeds limit of ${MAX_DEPTH}`)
  for (const selection of selectionSet?.selections ?? []) {
    if (selection.selectionSet) checkDepth(selection.selectionSet, depth + 1)
  }
}

Attack 3: Alias-batching DoS

GraphQL aliases allow the same field to be queried multiple times under different names in a single request. An LLM can construct a query with 500 aliases for the same expensive resolver — all in one HTTP request, bypassing rate limits that operate at the request level:

// malicious query constructed by a prompt-injected LLM:
// { a1: expensiveQuery(id:1) a2: expensiveQuery(id:2) ... a500: expensiveQuery(id:500) }
// — this is 1 HTTP request but triggers 500 resolver calls

import { createComplexityRule } from 'graphql-query-complexity'

const complexityRule = createComplexityRule({
  maximumComplexity: 100,  // total weighted cost per request
  variables: {},
  estimators: [
    // charge 1 for each field; aliases count individually
    fieldExtensionsEstimator(),
    simpleEstimator({ defaultComplexity: 1 }),
  ],
  onComplete(complexity) {
    if (complexity > 100) throw new Error(`Query complexity ${complexity} exceeds limit`)
  },
})

Attack 4: Query-string injection

The most MCP-specific risk: if the tool handler constructs a GraphQL query string from LLM-supplied arguments using string interpolation, a prompt-injected LLM can break out of the intended query structure and inject arbitrary fragments:

// WRONG: building the query string from LLM arguments
async function searchUsers(nameFromLLM: string) {
  const query = `{ users(filter: "${nameFromLLM}") { id email } }`
  // if nameFromLLM = '") { id email adminToken } users(filter: "x'
  // the injected query now fetches adminToken — a field the author never intended to expose
  return await graphqlClient.request(query)
}

// CORRECT: use GraphQL variables — the value is always treated as data, never as syntax
async function searchUsers(nameFromLLM: string) {
  const query = `
    query SearchUsers($name: String!) {
      users(filter: $name) { id email }
    }
  `
  const variables = { name: nameFromLLM }  // LLM value goes here, not in the query string
  return await graphqlClient.request(query, variables)
}

GraphQL variables are the correct defense: they are typed, parsed separately from the query document, and cannot affect the query structure regardless of their content. Any LLM argument that becomes a GraphQL input value must be passed as a variable — never interpolated into the query string.

What SkillAudit checks

See also

Check your GraphQL-proxying server for injection and depth findings.

Run a free audit → How grading works →