Skip to main content
Glama
cubicecho

@cubicecho/graphql-mcp

by cubicecho

@cubicecho/graphql-mcp

Turn a GraphQL schema into a Model Context Protocol server. Point it at a GraphQLSchema and every Query/Mutation root field becomes an MCP tool, described from your SDL — field and argument descriptions, types — so an AI can discover and call your API.

It's a thin wrapper meant to run side-by-side with your GraphQL server: mount the returned HTTP handler on a route in the same app, or run it as its own process and forward to a remote GraphQL endpoint.

Install

npm install @cubicecho/graphql-mcp
# peer deps
npm install @modelcontextprotocol/sdk graphql zod

Needs Node ≥ 22, @modelcontextprotocol/sdk ≥ 1.12, graphql ≥ 16, and zod 3.25+ or 4.x. createFetchHandler alone needs SDK ≥ 1.25.

zod is a peer dependency rather than a bundled one: the MCP SDK validates tool arguments against your copy, and a second copy inside this package would make those checks fail across the boundary. Bring whichever major you already use — both are tested.

Related MCP server: graphql-mcp-server

Quick start

Run the MCP endpoint beside your GraphQL endpoint in the same Express app:

import express from 'express';
import { createHttpHandler } from '@cubicecho/graphql-mcp';
import { schema } from './schema.js'; // your executable GraphQLSchema

const app = express();
app.use(express.json());

app.post('/graphql', /* your existing GraphQL handler */);
app.post('/mcp', createHttpHandler({ schema })); // ← the MCP server

app.listen(4000);

Given the schema from the brief:

"A user in the system"
type User {
  "The unique id for the user, a UUID"
  id: String!
  "The list of todos this user has created."
  todos: [Todo!]!
}

"A todo entity, able to be marked as completed"
type Todo {
  "The unique id for the todo, a UUID"
  id: String!
  "If the todo is complete or not."
  completed: Boolean!
  "A textual description of what the todo is."
  description: String!
  "The user who created this todo."
  createdBy: User!
}

type Query {
  todo(id: String!): Todo
  todos: [Todo!]!
}

type Mutation {
  "Create a new todo for a user."
  createTodo(input: CreateTodoInput!): Todo!
  setCompleted(id: String!, completed: Boolean!): Todo
}

…you get four tools — todo, todos, create_todo, set_completed — each with an input schema derived from the field's arguments and a description built from the SDL docstrings. (Tool names are snake_case by convention; pass nameCase: 'preserve' to keep your field names verbatim.) Calling create_todo runs the equivalent of:

mutation createTodo($input: CreateTodoInput!) {
  createTodo(input: $input) { id completed description __typename }
}

Concepts

Export

What it does

createHttpHandler(options)

Returns an Express/Node (req, res) handler serving the tools over the MCP Streamable HTTP transport. A fresh server is created per request.

createMcpServer(options)

Returns a single McpServer with all tools registered. Use for stdio or one long-lived connection.

connectServer(server, transport)

Connects a server to a transport. Use this instead of server.connect — see Connecting your own transport.

createServerFactory(options)

Builds the tool descriptors once and returns a () => McpServer factory.

createLocalExecutor(schema, opts?)

Executor that runs operations in-process via graphql-js (the default).

createHttpExecutor(endpoint, opts?)

Executor that forwards operations to a remote GraphQL HTTP endpoint.

buildTools(schema, opts?)

The pure core: schema → ToolDescriptor[] (no SDK, no executor).

buildOperationTools(schema, docs, opts?)

The same, from hand-written GraphQL documents — see Hand-written operations as tools.

Lower-level helpers (buildOperation, buildSelectionSet, argsToZodShape, registerGraphqlTools, compileRules, extendSchemaForMcp, stripRootTypes, buildMetaTools) and all types are exported too.

How fields become tools

  • Both queries and mutations become tools. MCP has no query/mutation distinction; queries are annotated readOnlyHint, mutations destructiveHint — see Write hints, because that mutation default is deliberately blunt.

  • Names are snake_case. createTodo becomes create_todo. The MCP spec doesn't mandate a convention, but every example in it names tools that way and so does most of the ecosystem, so it's what an agent has seen most. The humanized title (Create Todo) and the description still carry the real field name, and include/exclude patterns always match the GraphQL field name. Pass nameCase: 'preserve' for verbatim field names, or toolName for full control — return undefined from it to decline and keep the default for that field, so renaming two fields out of forty stays a two-line callback:

    // rename one field; every other tool keeps its default name
    toolName: (field) => (field.name === 'listTodosSingle' ? 'get_todo' : undefined),

    applyNameCase is exported for the other case — transforming a name and casing it the way the package does (applyNameCase(base)). Prefer either to hand-rolling snake_case, which agrees with ours until a field like parseURLFilter splits an acronym run.

  • Arguments → input schema. Each field's args are converted to a Zod schema (the MCP input-schema format): non-null args are required, scalars/enums/lists/ input-objects map across, custom scalars fall back to an opaque value (see Custom scalars).

  • Unknown arguments are rejected. Input objects — and the argument object itself — are strict, matching the additionalProperties: false the tool listing already advertises. A misspelled field comes back as an error naming the key, rather than a success with the value silently dropped, which is the failure an agent has no way to notice or retry.

  • Return type → selection set. A selection set is auto-generated: every scalar/enum leaf plus nested objects up to selectionDepth (default 2), always including __typename. Fields that require arguments and cyclic types are skipped. The depth is per field — see Selection depth.

  • Descriptions come from the SDL — the field docstring, its signature, and a per-argument list carrying each argument's default (as the GraphQL literal you'd write) and any argument-level deprecation. Each description also ends with the exact selection the tool will return, so an agent doesn't plan around fields it won't receive.

  • Deprecations are stated, not hidden. A field with @deprecated keeps its tool — it's often still the only way to do something — but the reason sits directly under the summary, where an agent reads it before choosing:

    The `legacyTodos` query.
    
    DEPRECATED — Use todos instead.

    Pass includeDeprecated: false to drop them from the tool surface entirely.

What a tool returns

Every tool — generated or meta — returns JSON text you can parse directly:

{
  "data": { "todos": [{ "id": "1", "__typename": "Todo" }] },
  "errors": [{ "message": "…", "path": ["todos", 1, "owner"] }],
  "note": "Partial result: some fields failed and are null in `data`; …"
}
  • isError means nothing usable came back. GraphQL happily returns data and errors when some fields resolve and others don't. Flagging that whole call an error makes an agent throw away rows it could have used, so isError is set only when no root field resolved — otherwise the result carries a note saying part of it failed.

  • Errors are condensed to message, path, and extensions (where app codes like UNAUTHENTICATED live). locations are dropped: they're line and column offsets into a query string the agent never wrote and can't see.

  • Results are clamped to maxChars (default 50_000), so one large collection can't flood the agent's context:

createMcpServer({ schema, maxChars: 20_000 });

The clamp is structural: whole array elements are dropped, evenly across every collection in the payload, and the body stays parseable JSON. Cutting the serialized text instead would leave the client a SyntaxError where its rows used to be — and would take errors and note with it, since they serialize last. errors, the partial-result note, and a truncated record are always kept.

  • A clamped result says what went missing, and names the argument to page with when the field has one. "This was cut" on its own leaves an agent with no move but to re-run the identical call:

{
  "data": { "todos": [{ "id": "1" }] },
  "truncated": {
    "droppedItems": 419,
    "totalItems": 420,
    "advice": "narrow the query or request fewer fields. This field paginates: pass `first` to cap the page size, then `after` to continue from where this page ended."
  }
}

The arguments are read off the schema, matching the conventions in wide use (first/after, limit/offset, take/skip, page/pageSize). A field with none keeps the plain advice.

When nothing can be dropped — one enormous scalar, say — data is left out entirely and truncated.dataOmitted says so, rather than handing back a value silently cut in half that an agent might act on.

That holds when the executor throws, too — a refused connection or a broken custom executor comes back as { "errors": [{ "message": "…" }] } with isError set, never as a bare string a client can't parse.

  • A malformed call answers in the same envelope, which matters because it's the failure an agent hits most: a wrong scalar, a misspelled key, a bad enum member. Each Zod issue becomes one error naming the argument it's about, so a call with two mistakes is told about both:

{
  "errors": [
    {
      "message": "Invalid input: expected number, received string at `limit`",
      "extensions": { "code": "BAD_INPUT" }
    },
    {
      "message": "Invalid option: expected one of \"LOW\"|\"HIGH\" at `filter.priority`",
      "extensions": { "code": "BAD_INPUT" }
    }
  ]
}

Choosing where GraphQL runs

The single seam is the executor. The default runs in-process against the schema you pass:

import { createMcpServer, createLocalExecutor } from '@cubicecho/graphql-mcp';

const server = createMcpServer({
  schema,
  executor: createLocalExecutor(schema, { rootValue, contextValue }),
});

To run the MCP server as a separate process and forward to a GraphQL HTTP server:

import { createHttpHandler, createHttpExecutor } from '@cubicecho/graphql-mcp';

const handler = createHttpHandler({
  schema, // used only to derive the tools
  executor: createHttpExecutor('http://localhost:4000/graphql', {
    // forward auth derived from the per-request context
    headers: (ctx) => ({ authorization: (ctx as { token: string }).token }),
  }),
});

Per-request context (auth)

Derive the GraphQL context from the incoming HTTP request — e.g. to forward an auth token into resolvers or the forwarding executor:

const handler = createHttpHandler({
  schema,
  contextFromRequest: (req) => ({ token: req.headers.authorization }),
});

For non-HTTP setups, pass context as a static value or a factory of the MCP request extra.

Choosing a tool surface

Before deciding which fields become tools, decide what kind of surface you are building. There are three, they compose, and each answers a different failure:

Surface

What an agent sees

Answers

Generated (the default)

one tool per root field, described from the SDL

open-ended reads over data nobody anticipated — every field is reachable without you having thought of the question

Curated (operations, typesOnly)

the handful of operations you wrote

known workflows and multi-step writes — the shape is one you chose, so there is less to get wrong

Exploratory (metaTools)

four navigation tools instead of a listing

a schema too large to project at all — the agent reads the parts it needs

The trade is one sentence: a curated surface is a bet that you know the questions. When the bet is right it wins decisively — in the measurement behind this package's argument-shape work, a curated arm made zero failed calls against a generated arm's three, on a listing over an order of magnitude smaller, with more than ten times the share of bytes spent on prose an agent actually reads.

When the bet is wrong it loses. On a bulk analytical read from the same comparison the generated surface won on call count, because raw field access is a natural join for a question no hand-written operation anticipated. If your agent's job is to answer things you haven't thought of, generate.

They compose, so it is rarely either/or: an operation overrides a generated tool by name, meta tools sit alongside both, and a tools entry beats everything. The usual shape is to generate, then hand-write the two or three tools whose argument shapes an agent keeps getting wrong.

Choosing which fields become tools

Allow/deny lists take graphql-shield-style patterns — a field name with optional * wildcards and an optional Query./Mutation. prefix:

const handler = createHttpHandler({
  schema,
  include: ['Query.*', 'createTodo'], // only these become tools (omit to keep all)
  exclude: ['delete*', 'Mutation.resetDb'], // wins over include
});

Patterns match GraphQL field names (not the snake_cased or renamed tool names), and they apply to every root field of the schema being wrapped — including fields added by extend (below), so an include list must name those too. Omitting include keeps every field; passing an empty array matches nothing and exposes no tools (it fails closed, so a computed-empty allow-list can't accidentally publish your whole API).

For anything the patterns can't express, the filter callback still composes with both lists:

createHttpHandler({ schema, filter: (field, kind) => !field.deprecationReason });

Custom scalars

Built-in scalars map to the obvious Zod types; a custom scalar (DateTime, JSON, URL) has no shape we can infer, so it falls back to an opaque value carrying the scalar's own SDL description — so documenting the format in your schema already helps:

"An ISO-8601 timestamp, e.g. 2026-08-30T12:00:00Z."
scalar DateTime
Custom scalar DateTime — An ISO-8601 timestamp, e.g. 2026-08-30T12:00:00Z.

The value still isn't validated on our side. Pass scalars for that:

import { z } from 'zod';

createHttpHandler({
  schema,
  scalars: {
    DateTime: z.string().datetime().describe('ISO 8601 timestamp'),
    URL: z.string().url(),
  },
});

Keys are scalar names, and the mapping is consulted before the built-ins, so you can retype ID or String too. A function form gets the GraphQLScalarType itself; return undefined to fall through:

createHttpHandler({
  schema,
  scalars: (scalar) => (scalar.name.endsWith('Date') ? z.string().date() : undefined),
});

Nullability, lists, and input-object nesting are applied around whatever you return — map the base type only.

Since the map is a plain Record<string, ZodTypeAny>, a generated one drops straight in. With @vantreeseba/graphql-zod:

import { defaultScalarMap } from '@vantreeseba/graphql-zod';

createHttpHandler({
  schema,
  scalars: { ...defaultScalarMap, DateTime: z.string().datetime() },
});

Tool arguments cross the wire as JSON, so keep the mapped types JSON-representable — z.string().datetime() rather than z.date().

Argument defaults

An argument's SDL default shows up in two places. The tool description states it in prose, and the rendered JSON Schema carries the default keyword:

type Query {
  list(limit: Int = 10, status: Status = OPEN): [T!]!
}
{ "limit": { "type": ["integer", "null"], "default": 10 },
  "status": { "anyOf": [{ "enum": ["OPEN", "DONE"] }, { "type": "null" }], "default": "OPEN" } }

The keyword is advisory. The value is not injected into the arguments, so an omitted argument stays omitted on the wire and GraphQL applies its own default — the SDL stays the single source of truth. An enum's default is rendered as its name, which is what a variable actually carries.

The description is careful about one thing worth knowing:

- `limit`: `Int` (omit for the default `10`; an explicit `null` is sent as null)

GraphQL does not read a passed null as a request for the default. Omitting the argument gets you 10; sending null gets you null. (Where nullBranches: 'never' is in force for that field, the caveat is dropped, since null can't be sent.)

On zod 3 the default keyword is absent — there is no metadata channel that doesn't also change parsing — and the prose carries it alone.

Argument shape examples

An argument whose type is an input object carries a compact JSON example in its description, showing the minimum a caller has to send:

Arguments (`shape:` shows a minimal JSON example — required fields only):
- `where`: `TaskFilters` — Filter the tasks returned.
  shape: {"name":{"eq":"string"}}
- `orderBy`: `[TaskOrderBy!]`
  shape: [{"startedAt":{"direction":"ASC","priority":0}}]
- `limit`: `Int` (omit for the default `50`; an explicit `null` is sent as null)

The shape was always in inputSchema, and that is exactly why this exists. Measured against a hand-written-operations arm on the same schema, the generated surface's only failed calls were argument shapes guessed from the argument's nameorderBy: { startedAt: "desc" } for a type that is really a nested object keyed by column, with an enum spelled ASC. The correct answer was in the JSON Schema. It was inside a listing where a fortieth of the bytes are prose, and the prose is what gets read. The examples add roughly one percent.

What goes in one, and what doesn't:

  • Every required field, however deep. An example missing one is valid-looking JSON the server rejects, which relocates the failure instead of removing it. If a required field can't be rendered — the only case is a type that contains itself — the whole example is dropped rather than shipped incomplete.

  • The first field of an all-optional object. A required-only rule renders {} for a filter type and teaches nothing, and rendering every optional field is the size problem again.

  • An enum's member as the schema spells it, which is the half of the measured failure that prose alone would not have fixed.

  • One element of a list, and a field's own default in place of a placeholder.

  • Nothing at all for a scalar argument, for an argument whose own default is already printed as a GraphQL literal on the line above, or for an example that outgrows its budget — past a few hundred characters it stops being a hint and becomes the schema again, in a second syntax.

exampleDepth bounds how far optional expansion goes, and 0 turns examples off — per schema, per field, or from the SDL:

createMcpServer({ schema, exampleDepth: 0 });
createMcpServer({ schema, exampleDepth: (field, kind) => (kind === 'query' ? 3 : 0) });
field.extensions = { mcp: { exampleDepth: 0 } };

Unlike selection depth there is no descriptor.exampleDepth and no decorate rebuild. Depth is on the descriptor because the query, the output schema and the description all have to agree about it; an example affects the description alone, and decorate can already replace that outright.

Trimming null branches

A nullable GraphQL argument is advertised two ways at once: it is absent from required, and it carries an explicit null branch — anyOf: [T, {"type": "null"}] for an input object, type: [X, "null"] for a scalar. The second is what costs. On a schema with a filter type per column those branches are around 40% of the schema nodes and 20% of the whole advertised listing.

There is also one shape with no legal rendering downstream:

{ "anyOf": [{ "$ref": "#/definitions/StringFilter" }, { "type": "null" }] }

Draft-07 has no way to say "nullable" next to a $ref — siblings of $ref are ignored and strict validators reject them. A consumer either keeps the combinator, which backends that compile every tool into one grammar refuse, or collapses it into an illegal node.

nullBranches: 'never' drops the branch:

createMcpServer({ schema, nullBranches: 'never' });

The argument's shape is not lost — required already says it may be absent. What is lost is the ability to send an explicit null, which becomes a validation error. For most GraphQL servers absent and null are the same thing, but not all: a mutation that clears a field with updateUser(bio: null) needs the branch. That is why the default is 'always', and why this is an option rather than a fix — only your schema knows which kind it is.

List elements are exempt under either setting. [String] permits a null element, and an element can be null but never absent, so dropping the branch there would change the type rather than compress it.

Per field

The trade above is rarely the same across a whole schema, because it usually splits by kind. On a generated CRUD surface a filter argument set to an explicit null is a caller mistake, while a mutation uses one to clear a column — so the reads can drop their branches and the writes must keep theirs. A callback says exactly that:

createMcpServer({
  schema,
  nullBranches: (field, kind) => (kind === 'query' ? 'never' : 'always'),
});

The callback receives the GraphQL field and its kind, and runs once per field. The same decision is available everywhere a per-field decision already lives:

// on the schema, where it is defined
field.extensions = { mcp: { nullBranches: 'never' } };

// or last, from decorate
decorate: (d) => (d.name === 'tasks' ? { nullBranches: 'never' } : undefined);

decorate rebuilds the input schema and the description at the new setting, because the per-argument advice about sending an explicit null is only true under 'always' — advice describing a call the tool now rejects is worse than none. Each descriptor records what it was built at, as descriptor.nullBranches.

One caveat if you post-process the listing. Splitting by kind means the same input type renders two ways across the surface — a TaskFilters with null branches under a mutation and without under a query. That is fine as MCP serves it: each tool's schema is converted on its own, so nothing collides. It stops being fine if you flatten every tool's $defs into one shared namespace downstream, where you get two definitions claiming one name. Split by kind when the read and write input families are disjoint (the generated-CRUD case), and key by (tool, type) if you merge.

Per input type

Splitting by kind is the common case, not the accurate one. What is actually true of a generated CRUD surface is that the filter types never legitimately take an explicit null, wherever they appear — and "wherever" includes arguments the per-field form cannot separate, because a mutation like updateTask(where: TaskFilters, set: TaskUpdate) takes both a filter and a patch on the same field. One mode has to serve both, and neither answer is right: 'never' breaks clearing a column, 'always' keeps every filter branch.

{ byType } keys the mode on the named input type instead:

createMcpServer({
  schema,
  nullBranches: { byType: (type) => (type.name.endsWith('Filter') ? 'never' : 'always') },
});

where loses its branches, set keeps its own, in the same tool.

The type handed to the callback is the type in the position — what is left after stripping ! and list wrappers — not the input object containing it. That is what makes it reach the top-level where argument, which has no containing type at all and is exactly the position rendering anyOf: [{"$ref": ...}, {"type": "null"}], the shape with no legal draft-07 form. Scalars and enums are passed too, so "objects keep their branch, scalars don't" is expressible.

Because the mode is a property of the type, every use of a named type renders the same body — so unlike the per-field callback, this form is safe to flatten into one downstream $defs namespace, and it carries into operations whole. The two compose: a per-field callback may return a { byType }, which picks a policy by kind and then lets the policy pick by type.

nullBranches: (_field, kind) => (kind === 'query' ? 'never' : { byType: filtersOnly });

There is deliberately no per-argument setting. A named input type is hoisted once under its GraphQL name, so rendering one type at two modes inside a single tool asks for two definitions under one id — which is an error from the JSON Schema conversion, not a size trade. Per-type has none of that: one type, one mode, one id, by construction.

Pruning input fields

nullBranches compresses how each field is rendered. Sometimes the problem is that the field is there at all.

Generated CRUD schemas emit a relation filter per foreign key, and those relation filters point at each other. A TaskFilters reaches TriggerListRelationFilter reaches TriggerFilters reaches TaskListRelationFilter — so one where argument on one tool drags in the filter type of every table it can reach through a join, transitively. On a schema of any size the closure is most of the advertised bytes, and an agent that only ever filters on a column never calls any of it.

include/exclude cannot reach this: they choose root fields, and the cost is inside an argument of a field you want to keep. inputField chooses fields of input objects:

createMcpServer({
  schema,
  inputField: (field) => !/ListRelationFilter/.test(String(field.type)),
});

The callback is handed the input field and the input object that contains it, and returns whether to advertise it. It runs during the walk, not after — so a type reached only through a pruned field is never visited, and never lands in definitions as an orphan nothing references. That is the whole point: pruning one field on TaskFilters deletes the closure behind it.

// keep a filter type's cheap operators and drop the rest
inputField: (field, parent) =>
  parent.name.endsWith('Filter') ? ['eq', 'in', 'contains'].includes(field.name) : true;

Pruning a non-null field throws at build time. The GraphQL server still requires it, so the tool would be advertised as callable and rejected on every call, for a reason an agent cannot see from the tool it was given. A broad predicate that catches one required field fails where a human is reading it rather than once per call in production. Make the field nullable in the schema, or spare it in the predicate.

Pruning every field of a type is allowed, and leaves an object that accepts {}. It is degenerate but coherent — better than a throw for a type a broad predicate reached and the caller never meant to name.

The callback must be a pure function of (field, parent). Input types are hoisted once per GraphQL name and memoized, so a predicate that answered differently depending on which tool was being built would ask for two definitions under one id.

Write hints

Queries are annotated readOnlyHint: true, idempotentHint: true, which is simply true of them. By default every mutation is annotated destructiveHint: true, idempotentHint: false, which is conservative rather than derived: the schema says a field writes, not what it writes, so a create is flagged the same as a delete.

That default under-reports nothing, but the hint's only real consumer is a client deciding whether to interrupt the operator for confirmation. Spent on every mutation, it is spent on none in particular — an operator who confirms create_task a dozen times a day is being trained to click through the dialog that also guards delete_task.

mutationHints: 'byName' opts into reading the conventional prefixes that generated schemas use:

createMcpServer({ schema, mutationHints: 'byName' });

Field name

destructiveHint

idempotentHint

create*, add*, insert*

false

false

delete*, remove*, destroy*

true

true

anything else

true

false

A prefix matches only on a word boundary — createTask, create_task, and create match; creationFor doesn't — and it's read from the GraphQL field name, so nameCase, toolName, and extensions.mcp.name can't change what a tool claims about itself. Everything unmatched keeps the conservative default, which is already right for update*/set* and is the only safe answer for a name the convention says nothing about (runTask, stopTask).

It's opt-in because it changes what clients confirm on, and no existing server should have that change under it on a minor upgrade.

Either way this is a naming convention, not knowledge. Where the convention is broken or absent, say so directly — per field in the schema, or across the board with decorate:

decorate: (descriptor) =>
  descriptor.name === 'run_task' ? { annotations: { destructiveHint: false } } : undefined,

Annotations merge rather than replace, so overriding one hint keeps the rest.

Selection depth

selectionDepth decides how far a generated selection set descends into nested objects. The default is 2, and one number for the whole schema is usually wrong in both directions: the field returning a flat row wants 1, and the one whose answer is only useful two objects down wants 3.

Pass a callback to decide per field:

createMcpServer({
  schema,
  selectionDepth: (field, kind) => (kind === 'mutation' ? 1 : field.name === 'tasks' ? 3 : 2),
});

A number still works and applies to every field. Per field, extensions.mcp.selectionDepth beats the option, and a decorate patch beats both:

decorate: (descriptor) => (descriptor.name === 'tasks' ? { selectionDepth: 3 } : undefined),

A patched depth rebuilds the operation, the description, and the outputSchema around the new selection, so a descriptor never describes a selection it won't return. Setting query in the same patch still wins over the rebuilt one.

Depth is not free in both directions: each level multiplies the fields the server resolves and the tokens the agent reads, while a level too few means the agent gets an object it can't see into and has no second tool to ask with. descriptor.selectionDepth reports what each tool was built at.

Decorating tools for agents

Descriptions come from your SDL, but agents often need more: workflow hints, warnings, when-to-use guidance. Two ways to layer that on without touching the public GraphQL surface:

In schema code, via extensions.mcp on a field (read at tool-build time):

// graphql-js / @graphql-tools/schema field definition
fields: {
  todos: {
    type: TodoList,
    extensions: {
      mcp: {
        appendDescription: 'Prefer this over `todo` when listing; filter by status.',
        title: 'List Todos',
        // also: hidden, name, description, annotations,
        // selectionDepth, nullBranches, exampleDepth
      },
    },
  },
}

Programmatically, via the decorate callback — the last word on every generated descriptor:

createHttpHandler({
  schema,
  decorate: (descriptor, field, kind) =>
    kind === 'mutation'
      ? { description: `${descriptor.description}\n\nConfirm with the user first.` }
      : undefined, // keep as-is
});

Precedence: SDL-derived defaults < extensions.mcp < decorate.

Rewriting a tool's argument shape

A generated argument surface is the schema's shape, not the shape an agent finds easy. mapArgs lets a tool advertise the second while still sending the first, so flattening one awkward argument no longer means hand-writing the operation behind it:

decorate: (descriptor) =>
  descriptor.name === 'tasks'
    ? {
        inputSchema: { id: z.string() },
        description: 'Fetch one task by id.',
        mapArgs: (args) => ({ where: { id: { eq: args.id } } }),
      }
    : undefined;

args has already been validated against the schema you advertised — the advertised schema and the pre-call validator are the same object, so replacing it is coherent end to end and unknown keys are still rejected. The mapper may be async, and it receives the SDK's per-call extra as its second argument, so it can inject something request-scoped. Every key it returns has to be a variable the operation declares.

Two failures come back in the usual JSON envelope rather than as exceptions:

  • The mapper threwBAD_INPUT, carrying its message. A mapper is where server-side argument rules naturally go, and that is the code an agent already reads as "fix your arguments and retry".

  • The mapper returned a key the operation doesn't declareBAD_TOOL_CONFIG, naming the tool. graphql-js discards an undeclared variable silently, so without this the call succeeds with the mapped intent thrown away, which is the expensive failure when the caller is a model. The message says retrying will not help, so an agent stops rather than looping on its own arguments.

Setting mapArgs and inputSchema without also setting description is refused at startup, naming the tool. The generated description still lists the field's own arguments — down to the shape: example, which would confidently show a literal for an argument the tool now rejects. A mapper that keeps the advertised shape (injecting a tenant id, reordering) sets no inputSchema and is unaffected.

The meta tools are not out of step when they still print tasks(where: TaskFilters): they describe the schema, and graphql_execute runs schema documents where that is exactly right. mapArgs reshapes one tool's front door, not the graph behind it.

Designing a surface agents get right

Once a surface exists, most of what goes wrong with it is one thing.

Argument shapes are the largest single source of failed calls. Every failure in the comparison above was the same mistake: a shape guessed from an argument's name. A generator that emits orderBy: { <column>: { direction, priority } }, where: { <column>: { eq } }, or set: where the sibling mutation says values: is asking a model to guess, and it will guess the shape it has seen most often elsewhere. The correct shape was in the JSON Schema the whole time — inside a listing far too large to read. A model reads the description.

This package now writes a literal example into the prose for you (Argument shape examples), which is on by default for exactly that reason. Two levers for what it can't reach: exampleDepth: 0 turns it off for a field whose example is noise, and decorate replaces the description outright — or, with mapArgs, replaces the argument shape itself so there is nothing awkward left to explain.

Prose-to-bytes is a diagnostic worth running once. Ask a live server for its listing and compare the description text against the whole payload:

const { tools } = await client.listTools();
const all = JSON.stringify(tools).length;
const prose = tools.reduce((n, t) => n + (t.description?.length ?? 0), 0);
console.log(`${((prose / all) * 100).toFixed(1)}% prose across ${tools.length} tools`);

A surface that is a couple of percent prose is nearly all machine-readable schema an agent will skim past, and nullBranches, inputField, selectionDepth and include/exclude all still have leverage on it. A surface that is a third prose is done — spend the effort elsewhere.

Then read the descriptions the way an agent would. A green test suite answers whether the tool works, not whether it can be called correctly the first time. Print one tool's description, cover the schema, and ask whether you could write the call. See also What a tool returns for the result shape they will be reading back.

MCP-only schema extensions

Expose fields to agents that don't exist on your public GraphQL API — usage guides, aggregate helpers — by passing extension SDL (+ resolvers). The schema is merged with @graphql-tools/schema before tool generation:

const handler = createHttpHandler({
  schema,
  extend: {
    typeDefs: /* GraphQL */ `
      extend type Query {
        "How an agent should use this API."
        usageGuide: String!
      }
    `,
    resolvers: {
      Query: { usageGuide: () => 'List todos before creating duplicates…' },
    },
  },
});

The extended schema feeds both tool generation and the default in-process executor. If you pass a custom executor (e.g. createHttpExecutor forwarding to a remote endpoint), that endpoint won't know the extended fields — keep MCP-only fields on the local path.

A tool-specific operation surface (typesOnly)

include/exclude subtract from the root fields you already have. When you'd rather design the agent's operations from scratch — different names, different arguments, coarser granularity — set typesOnly: true. The base schema's Query/Mutation/Subscription types are dropped and everything else (objects, inputs, enums, interfaces, unions, custom scalars with their serializers intact) is kept, so your SDL can still refer to the real types:

const handler = createHttpHandler({
  schema, // your real, full schema
  extend: {
    typesOnly: true,
    typeDefs: /* GraphQL */ `
      type Query {
        "The one search an agent should use. Returns at most 20 todos."
        findTodos(text: String!, status: TodoStatus): [Todo!]!
      }
    `,
    resolvers: {
      Query: { findTodos: (_, args, ctx) => searchTodos(args, ctx) },
    },
  },
});

Todo and TodoStatus came from the real schema — you write the operations, not the types. Two consequences worth knowing:

  • Your typeDefs must declare type Query { … } (not extend type Query), since there's no base root type left to extend. Omitting it throws.

  • Nothing from the original root types survives, so every field needs a resolver — the base schema's are gone with it.

stripRootTypes(schema) is exported if you want the stripped schema on its own.

typesOnly and operations chase the same goal from opposite ends of how much you reimplement: typesOnly drops the root types, so every field you expose needs a resolver you write, while operations reuses the real schema's resolvers untouched and only changes what is asked of them.

Hand-written operations as tools

The generated surface is every root field, described from the SDL. Sometimes you want the opposite: a handful of operations you wrote deliberately, with the selection, the name, and the prose all chosen. Pass them as operations:

import { globSync, readFileSync } from 'node:fs';
import { Source } from 'graphql';

const handler = createHttpHandler({
  schema,
  operations: globSync('mcp/*.graphql').map(
    (path) => new Source(readFileSync(path, 'utf8'), path),
  ),
});
# Every task on the board, newest first.
# Use this instead of filtering the raw table.
query openTasks(
  # How many to return.
  $limit: Int! = 20
) {
  tasks(where: { status: { eq: OPEN } }, orderBy: [{ startedAt: { direction: DESC } }], limit: $limit) {
    id
    title
    assignee { name }
  }
}

That becomes a tool named open_tasks, taking one optional limit, described by the comments above the operation and above the variable — the only place GraphQL lets you write prose about either. Four mappings are worth knowing:

  • The operation name is the tool name, through nameCase (openTasksopen_tasks). The document still runs under its own name.

  • Variables are the arguments. $limit: Int! = 20 is advertised as optional with a default of 20 — the non-null says it is never null, not that you must send it. Defaults, deprecation, and the explicit-null caveat render exactly as they do on a generated tool.

  • Write hints come from the operation name under mutationHints: 'byName', which is a better signal than a generated field name: you chose it.

  • A $limit/$offset pair earns the same truncation advice a paging field gets, for free.

Fragments may live in their own file. Every document is merged before validation, so an operation in one file can spread a fragment defined in another; each tool then carries only the fragments it actually reaches. A shared fragment file holding fragments this run doesn't use is fine.

Everything is checked at boot. A syntax error, an unknown field, a mistyped variable, a duplicate operation name, an anonymous operation, a subscription, or a glob that matched nothing all throw when the handler is built — naming the file and position, which is why the option is worth passing a Source:

graphql-mcp: `operations` failed to validate against the schema:
  - Cannot query field "titel" on type "Task". Did you mean "title"? (tasks.graphql:7:5)

The option takes documents, never paths. A server factory is synchronous, so it cannot await import('node:fs'), and importing node:fs at the top level would make this package unloadable on the fetch runtimes it also serves. Node 22 ships globSync, so reading the files is the one line above and stays in your code, where your bundler can see it.

Composing with the generated surface

An operation replaces a generated tool of the same name, exactly as a tools entry does. Final precedence is generated < operations < meta < tools. That is the incremental path: keep the sixteen generated tools that work and hand-write only the one whose argument shape an agent keeps getting wrong.

operations: [readFileSync('mcp/tasks.graphql', 'utf8')], // named `query tasks`

Go the other way with include: [], which leaves only what you wrote.

nameCase, scalars, nullBranches, inputField, mutationHints, exampleDepth, maxChars, context, executor and extend all apply to operation tools — and because documents validate against the extended schema, an operation may select an MCP-only field. Their callback forms mostly don't: a nullBranches callback is handed a GraphQLField, and an operation has none. inputField is the exception, and so is a nullBranches: { byType } — both are already pure functions of the input type, so they have nothing to say about the root field an operation lacks, and both carry over whole.

include/exclude/filter do not apply, and that is deliberate. They match GraphQL field names and govern how the schema is projected; making them filter operation names would make the include: [] example above expose nothing at all. So an operation may select a root field exclude denies. An operation is your own code, at the same trust level as a tools handler — and exclude still governs the generated surface and the agent-written documents graphql_execute runs, which is where it was ever protecting anything.

decorate doesn't apply either. Its signature needs a GraphQLField, and you own this document: edit it.

Schema-exploration tools (large schemas)

One tool per root field stops scaling somewhere past a few dozen fields — the tool list itself starts crowding the agent's context. metaTools swaps that for a handful of tools that let an agent navigate the schema instead:

const handler = createHttpHandler({
  schema,
  includeQueries: false, // no per-field tools at all…
  includeMutations: false,
  metaTools: true, // …just these four
});

Tool

What it does

graphql_introspect

Prints a type's SDL — plus a JSON shape example when it is an input type; with no argument, the callable root fields plus every type name.

graphql_search

Finds types and fields by substring, across names and descriptions.

graphql_validate

Checks a document against the schema without running it.

graphql_execute

Runs a document, with variables.

The two modes compose — leave the generated tools on and add meta tools for the long tail. Names collide by design: a tools entry overrides a meta tool, which overrides an operation, which overrides a generated one.

execute respects your allow-list. It runs documents the agent wrote, so without a check it would be a way around include/exclude. Every root field of the incoming document is matched against the same rules (fragment spreads and inline fragments expanded, so nothing hides behind one), and a mutation is refused unless includeMutations allows it. Override per-tool if the exploration surface should differ from the generated one:

metaTools: {
  tools: ['introspect', 'search', 'execute'], // skip `validate`
  prefix: 'todo_api_',                        // default `graphql_`
  include: ['Query.*'],                       // defaults to the server's rules
  allowMutations: false,
  maxChars: 20_000,                           // result budget, default 50k
}

Custom tools & overrides

Add bespoke tools, or override a generated one by reusing its name (the surface stays the same; only that tool's behaviour changes):

const server = createMcpServer({
  schema,
  tools: [
    {
      name: 'create_todo', // overrides the generated tool for the `createTodo` field
      description: 'Create a todo, with extra validation.',
      inputSchema: { description: z.string().min(1) },
      handler: async (args) => ({
        content: [{ type: 'text', text: `created: ${args.description}` }],
      }),
    },
  ],
});

A custom tool that runs GraphQL itself should reuse the same result handling the generated tools use, rather than rolling its own — runExecutor turns a thrown executor into an { errors } result, and toCallToolResult applies the partial-result, error-condensing, and clamping rules described in What a tool returns:

import { runExecutor, toCallToolResult } from '@cubicecho/graphql-mcp';

const executor = createLocalExecutor(schema, { rootValue });

tools: [
  {
    name: 'urgent_todos',
    description: 'Todos due today, sorted by priority.',
    handler: async () => {
      const result = await runExecutor(executor, {
        query: '{ todos(status: OPEN) { id description } }',
      });
      return toCallToolResult(result);
    },
  },
];

Reuse it for the failure path too. A custom tool that returns a plain payload on success still returns the { errors: [...] } envelope when its arguments don't validate, because guardToolArguments answers above the handler — so a tool that invents its own success shape shows an agent two different result shapes for the one tool. BAD_INPUT, the extensions.code those envelopes carry, is exported, so a tool that rejects a call on its own rules can answer with the same code the generated ones do.

Prompts, resources, and the rest of the SDK

This package generates tools. Everything else the MCP SDK can serve — prompts, resources, completions — is reached with decorateServer, a hook that runs against each freshly minted McpServer before it is connected:

const handler = createHttpHandler({
  schema,
  executor,
  decorateServer: (server) => {
    server.registerPrompt(
      'triage',
      { title: 'Triage', description: 'How to triage a todo.', argsSchema: {} },
      () => ({ messages: [{ role: 'user', content: { type: 'text', text: 'Triage it.' } }] }),
    );
  },
});

server is the SDK's own McpServer, so its full API is available and nothing here needs to model it. The option is on createMcpServer, createServerFactory, createHttpHandler and createFetchHandler alike.

The hook has to run where it does. A server can only declare its capabilities while no transport is attached — register a prompt after connect and the client is told at initialize that there are no prompts, so it never asks. That window is between minting the server and connecting it, which is the window this hook occupies. It is why registering prompts on the server your own code holds works for a single stdio process and silently serves nothing under createHttpHandler, which mints a server per request.

Two things to know:

  • The hook is synchronous. Anything awaited between minting a server and connecting it is registration racing initialize. registerPrompt and registerResource are synchronous, so nothing is lost; a hook that returns a promise is refused with an error rather than left to fail under load. If your registrations need data, load it once outside the hook and close over it. A hook that throws fails every request it runs for — on Express 4 a rejected promise hangs the request instead of answering it, so wrap your handler in an error-catching adapter.

  • Vary prompts and resources freely; do not vary tools. The rendered tools/list is shared across every server one factory mints (Sessions mints one per session), so a hook that registers a different tool set depending on external state will serve one caller's listing to another. Prompts and resources are not cached and may differ per server. Tools that vary belong in the tools option, which also gets the BAD_INPUT envelope — a tool registered through this hook is not covered by the argument guard, so a malformed call to it gets the SDK's raw JSON-RPC error rather than the JSON result envelope every other tool answers with.

Connecting your own transport

createHttpHandler and createFetchHandler connect their servers for you. If you build the transport yourself — stdio, or one long-lived connection — use connectServer rather than server.connect:

import { createMcpServer, connectServer } from '@cubicecho/graphql-mcp';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

const server = createMcpServer({ schema });
await connectServer(server, new StdioServerTransport());

It connects, then makes the server tolerant of a tools/call that leaves params.arguments out. That key is optional in the MCP spec, and a tool whose arguments are all optional — or which takes none at all — gives a client nothing to put there. Without this, such a call is rejected by input validation before the tool runs. A prompts/get for a prompt registered with an empty argument schema is fixed up the same way, for the same reason.

Other HTTP servers

createHttpHandler returns a framework-agnostic handler: it needs a Node IncomingMessage and a Node ServerResponse, and nothing else. A parsed JSON body on req.body (as express.json() provides) is used when present, but the transport reads the request stream itself when it isn't — so a bare node:http server works with no body parser at all:

import http from 'node:http';

const handler = createHttpHandler({ schema });
http.createServer((req, res) => handler(req, res)).listen(4000);

Non-Node runtimes

Cloudflare Workers, Deno, Bun, and Hono speak Request/Response rather than Node's IncomingMessage/ServerResponse. createFetchHandler takes the same options and returns a fetch-shaped handler:

import { createFetchHandler } from '@cubicecho/graphql-mcp';

const handler = createFetchHandler({ schema });

export default { fetch: handler }; // Cloudflare Workers / Deno / Bun
app.all('/mcp', (c) => handler(c.req.raw)); // Hono

It needs @modelcontextprotocol/sdk 1.25 or later, which is where the SDK's web-standard transport was added. The import happens on the first call rather than at module load, so this package still loads on the older SDKs its peer range allows — only createFetchHandler is unavailable there, and it says so.

Sessions

Both handlers are stateless by default: every request gets its own server and transport, so any instance can serve any call and nothing has to be cleaned up. That is the right shape for a tool server, and it's what you want unless you need the server to send something unprompted.

Setting sessions flips that. The client initializes once, gets an Mcp-Session-Id back, and every later request is routed to the same long-lived server — which is what makes progress notifications and the standalone SSE stream possible, since a connection stays open to deliver them on.

const handler = createHttpHandler({
  schema,
  sessions: {
    idleTimeoutMs: 5 * 60 * 1000, // evict a client that walked away (default)
    maxSessions: 1000, // LRU cap on live sessions (default)
    enableJsonResponse: false, // SSE; set true behind a buffering proxy
    replay: true, // buffer events so a dropped stream can resume (default)
  },
});

// Close open streams on shutdown; a no-op when stateless.
process.on('SIGTERM', () => handler.close());

An unknown or expired session id is answered with 404, which tells a spec-compliant client to initialize again. The session table is per-process memory, which is what makes the deployment shape matter — see Running more than one instance.

Resuming a dropped stream

The stream is the reason to be stateful, and streams drop. A client that loses its SSE connection reconnects with the SSE Last-Event-ID header, saying how far it got; the server sends what came after. That only works if something kept the events, so each session gets a bounded in-memory buffer — without one the transport never even writes an event id, and a long tool call's result is simply gone when the connection dies mid-flight.

Tune the bounds, or turn it off:

sessions: {
  replay: { maxEventsPerStream: 64, maxStreams: 4 }, // the defaults
}

maxEventsPerStream is the reconnect window: a client that misses more than that while disconnected can't resume and must start a new stream. It is told so — a resume from an event that has aged out is answered 400 rather than with a stream that silently skips the gap, because a client that believes it caught up has no way to find out otherwise. The memory ceiling is the product of the three caps: maxSessions × maxStreams × maxEventsPerStream messages.

replay: false turns resumability off, which is what the SDK does unaided.

Buffers live in the process that owns the session, so they don't survive a restart or reach another replica. For that, supply your own store — a factory called once per session, returning anything with the EventStore shape (Redis, a Durable Object, a table):

import type { EventStore } from '@cubicecho/graphql-mcp';

sessions: { replay: (): EventStore => new RedisEventStore(redis) };

Running more than one instance

A session owns a live McpServer: an open connection, a connected transport, and registered handlers. That is not a value you can write to Redis and read back somewhere else, so a session cannot move between instances. Everything below follows from that.

Stateless (the default). Nothing is retained between requests, so any instance serves any call. Scale it however you like. This is the right answer unless you need server-initiated messages.

Stateful, one process. Zero config — the local table is the whole truth.

Stateful, behind a load balancer. You need sticky routing on Mcp-Session-Id, because a request that reaches the wrong instance cannot be served there. Two ways to arrange it:

  • Encode the instance in the session id with generateSessionId, and have the proxy route on it. No shared state at all.

  • Share a session directory — a small record of which instance holds which session id, in Redis or a table — and route on that.

Stateful, isolate-per-request (Cloudflare Workers). Sticky routing here means a Durable Object per session: route by Mcp-Session-Id to the object that owns it, and inside that object createFetchHandler is an ordinary single-process handler. Without that, stay stateless.

Session directories

A directory records session ownership, never the session. Supplying one does not make a session portable; it makes a misrouted request explain itself. Without one, a request that lands on the wrong instance gets a bare 404 — the same answer as an expired session, which is a miserable thing to debug when a load balancer quietly loses its stickiness. With one, the response says which instance holds it and repeats it in an Mcp-Session-Owner header:

HTTP/1.1 404 Not Found
Mcp-Session-Owner: web-2

{"jsonrpc":"2.0","error":{"code":-32001,
 "message":"Session not found on this instance; it is held by 'web-2'"},"id":null}

It is still a 404: the client's correct move is to initialize again, and there is no session here to forward the request to. What changed is that your proxy — or the person reading the logs — can now see where it should have gone.

Three methods, over whatever store you already run:

import type { SessionDirectory } from '@cubicecho/graphql-mcp';

const directory: SessionDirectory = {
  // Called on registration and on every later use, so it doubles as the TTL
  // refresh. Make it idempotent.
  claim: (id, owner) => redis.set(`mcp:${id}`, owner, { EX: 600 }),
  owner: (id) => redis.get(`mcp:${id}`).then((v) => v ?? undefined),
  release: (id) => redis.del(`mcp:${id}`),
};

const handler = createHttpHandler({
  schema,
  sessions: { directory, instanceId: process.env.HOSTNAME },
});

instanceId defaults to a random UUID, which distinguishes instances but tells you nothing — set it to a pod name or hostname if you mean to route on it, and to something you're willing to disclose, since a misrouted request is answered with it. Give claims a TTL so an instance that dies doesn't leave its sessions attributed to it forever; claim is re-issued on every request, so a live session is always refreshed well before it lapses.

MemorySessionDirectory implements the interface in local memory. It is a test double and a template — memory is exactly what several instances don't share.

Development

npm test                # node --test (built-in runner, type stripping)
npm run coverage        # node --test with built-in coverage + thresholds
npm run typecheck       # tsc --noEmit
npm run typecheck:tests # type-check the test files too
npm run build           # compile to dist/
npm run check           # biome lint + format check

The source uses .ts import specifiers so it runs unbuilt under Node's type stripping; tsc rewrites them to .js on build. Requires Node ≥ 22 and TypeScript ≥ 5.7.

Commits follow Conventional Commits and drive automated releases: pushes to main run the Test workflow, and on success the Release workflow runs semantic-release to version, update the changelog, publish to npm, and tag a GitHub release.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Turn any GraphQL API into MCP tools. Zero config, zero code. Auto-introspection, flat InputObject schemas for better LLM accuracy, smart truncation, retry logic with exponential backoff.
    39 npm
    1
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    Automatically generates MCP tools from any GraphQL API by introspecting its schema, supporting queries, mutations, and authentication.
    5
    GPL 3.0
  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables AI agents to interact with any GraphQL API by introspecting the schema and exposing queries and mutations as MCP tools, with built-in pagination, semantic search, and framework adapters.
    14 npm
    MIT