@cubicecho/graphql-mcp
Provides tools for interacting with GraphQL APIs, turning each query and mutation field into an MCP tool with input schemas derived from the GraphQL schema.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@@cubicecho/graphql-mcpCreate a todo 'Buy groceries'"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
@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 zodNeeds 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 |
| Returns an Express/Node |
| Returns a single |
| Connects a server to a transport. Use this instead of |
| Builds the tool descriptors once and returns a |
| Executor that runs operations in-process via graphql-js (the default). |
| Executor that forwards operations to a remote GraphQL HTTP endpoint. |
| The pure core: schema → |
| 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, mutationsdestructiveHint— see Write hints, because that mutation default is deliberately blunt.Names are
snake_case.createTodobecomescreate_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 humanizedtitle(Create Todo) and the description still carry the real field name, andinclude/excludepatterns always match the GraphQL field name. PassnameCase: 'preserve'for verbatim field names, ortoolNamefor full control — returnundefinedfrom 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),applyNameCaseis 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 likeparseURLFiltersplits 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: falsethe 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
@deprecatedkeeps 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: falseto 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`; …"
}isErrormeans nothing usable came back. GraphQL happily returnsdataanderrorswhen some fields resolve and others don't. Flagging that whole call an error makes an agent throw away rows it could have used, soisErroris set only when no root field resolved — otherwise the result carries anotesaying part of it failed.Errors are condensed to
message,path, andextensions(where app codes likeUNAUTHENTICATEDlive).locationsare dropped: they're line and column offsets into a query string the agent never wrote and can't see.Results are clamped to
maxChars(default50_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 ( | 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 ( | 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 DateTimeCustom 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
name — orderBy: { 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 |
|
|
|
|
|
|
|
|
anything else |
|
|
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 threw —
BAD_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 declare —
BAD_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
typeDefsmust declaretype Query { … }(notextend 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(openTasks→open_tasks). The document still runs under its own name.Variables are the arguments.
$limit: Int! = 20is 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-nullcaveat 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/$offsetpair 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 |
| 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. |
| Finds types and fields by substring, across names and descriptions. |
| Checks a document against the schema without running it. |
| Runs a document, with |
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.registerPromptandregisterResourceare 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/listis 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 thetoolsoption, which also gets theBAD_INPUTenvelope — 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)); // HonoIt 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 checkThe 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.
This server cannot be deployed
Maintenance
Related MCP Connectors
The Grafbase MCP server sits in front of a GraphQL API and exposes an MCP protocol-compliant interface that allows AI agents and LLMs to explore and query GraphQL APIs using natural language. It provides tools to search schemas, introspect types and fields, and execute GraphQL queries while minimizing context bloat by returning only relevant schema subsets, with built-in support for authentication, authorization, and configurable access control.
- typeshipOAuthdev.typeship
Generate a typed SDK, CLI, and MCP server from any OpenAPI or GraphQL spec, and keep them current.
- SchemaOAuthai.schemalabs
The AI that understands raw data: Schema over your tables and databases, as MCP tools.
The OpenRouter for tools. One MCP connection gives any AI agent 254 hosted tools, pay per call.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceTurn 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 npm1MIT
- AlicenseNot gradedqualityDmaintenanceAutomatically generates MCP tools from any GraphQL API by introspecting its schema, supporting queries, mutations, and authentication.5GPL 3.0
- AlicenseNot gradedqualityDmaintenanceAutomatically discovers and exposes any GraphQL API as MCP tools with zero configuration.MIT
- AlicenseNot gradedqualityAmaintenanceEnables 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 npmMIT