Turning a graphql-ruby Endpoint into an MCP Server the Right Way
Recently I wanted to wire one of my Rails apps into an MCP client so an LLM agent could call my GraphQL API directly. The app already had a solid graphql-ruby setup, real resolvers, authorization context, complexity limits, the works. The MCP path seemed obvious: annotate some fields, generate tool definitions, done.
That instinct is wrong. I built a POC to figure out what the right abstraction actually is, and it turned out to be more interesting than I expected. There’s a clean architecture answer, a useful CI trick, and at least one subtle bug that will make your MCP tools silently fail on any error path. This post walks through all three.
The repo is at ruby-graphql-mcp-poc if you want to follow along.
The Wrong Abstraction: Field-Level Exposure
When you first think about “expose my GraphQL API as MCP tools,” the natural unit feels like a field. You have field :products on your query type, make that a tool. You have field :rename_product on your mutation type, make that a tool too.
The problem is that a field knows its arguments and its return type, but it does not define which nested fields to return. For products returning [Product], the field definition tells you Product has id, name, price_cents, maybe category, variants, reviews… but it says nothing about which of those to actually select. Someone has to decide, and auto-expanding doesn’t work:
- Graphs are cyclic.
Product -> Category -> products -> Product -> ...will loop. - Lists are unbounded. Selecting every field on every nested object on every result item is a context-window disaster.
- Fields have wildly different costs.
idis a database column.recommendedProductsmight call an ML service. - Authorization differs per field. Your
internal_cost_basisfield might be restricted to admin users. An auto-generated selection set would either expose it or silently drop it, neither is right.
So field-level exposure forces you to either hard-code selection sets somewhere (which is just writing operations in disguise) or leave these problems unsolved.
The Right Unit: A Committed GraphQL Operation
The right boundary is a named, committed .graphql operation file. Here’s what that looks like in the POC:
# List products, optionally filtering by a term in the product name.
query ListProducts($query: String) {
products(query: $query) {
id
name
priceCents
}
}
# Rename one product and return its updated public fields.
mutation RenameProduct($id: ID!, $name: String!) {
renameProduct(id: $id, name: $name) {
id
name
priceCents
}
}
Each operation file gives you everything you need to generate an MCP tool automatically:
- Variables become the MCP tool’s input schema (compiled from the variable definitions)
- Selection set becomes the output schema (compiled by walking the selection against the live schema types)
- Leading comment becomes the tool description
- Operation name becomes the tool name
The mapping is clean and deterministic. The code that does the compilation sits in McpGraphql::InputSchemaCompiler and McpGraphql::OutputSchemaCompiler, two services that walk GraphQL AST nodes and emit JSON Schema.
This is also independently where Apollo landed with their MCP Server. Their external Rust proxy uses the same operation-first design. The interesting thing for Ruby shops is doing it in-process through graphql-ruby itself, reusing your real schema, your real resolvers, your real auth context, and your real complexity limits. You’re not standing up a sidecar or duplicating your schema description, you’re just adding a transport.
The tool execution is correspondingly simple:
MCP::Tool.define(
name: operation.tool_name,
description: operation.description,
input_schema: operation.input_schema,
output_schema: operation.output_schema
) do |server_context: nil, **arguments|
result = schema.execute(
document: operation.document,
operation_name: operation.name,
variables: arguments.transform_keys(&:to_s),
context: { invoked_via_mcp: !server_context.nil? }
).to_h
MCP::Tool::Response.new(
[{ type: "text", text: JSON.generate(result) }],
structured_content: result
)
end
The schema executes the operation normally. MCP is just the discovery and invocation transport on top.
Treating MCP Exposure as a Contract Subject to Drift Review
Here’s where things get interesting from an engineering process perspective.
Because the input and output JSON Schemas are generated from (live schema + committed operation) and never hand-written, you can make schema drift structurally impossible to ignore. The POC generates a deterministic manifest at config/mcp_tools.json that includes:
- A SHA-256 of the full schema definition
- Per-operation SHAs of the
.graphqlsource - The full generated input and output JSON Schemas for each tool
The bin/rails mcp:generate task writes this file. The bin/rails mcp:check task is the CI gate:
task check: :environment do
generated = McpGraphql::Manifest.new.to_json
abort "Missing #{manifest_path}. Run bin/rails mcp:generate." unless manifest_path.exist?
abort "#{manifest_path} is stale. Run bin/rails mcp:generate and commit the result." unless manifest_path.read == generated
puts "MCP operations are valid and #{manifest_path} is current."
rescue McpGraphql::ValidationError => e
abort "MCP operation validation failed:\n#{e.message}"
end
Let me show you what this looks like in practice. Say you rename Product.name to Product.title in the schema but leave list_products.graphql untouched. Running mcp:check fails with:
MCP operation validation failed:
Field 'name' doesn't exist on type 'Product'
You fix the operation to use title. But mcp:check still fails:
config/mcp_tools.json is stale. Run bin/rails mcp:generate and commit the result.
You have to run mcp:generate, review the diff (the output schema’s name property became title), and commit it. That diff shows up in code review. Anyone reviewing the PR can see exactly what changed in the MCP contract.
This “drift-as-CI” framing is the most practical takeaway from this whole project. The committed manifest is your MCP API contract, version-controlled like any other contract, with an automated gate that catches breaks before they ship.
The Subtle Bug: Why Your Output Schema Will Break on Error Paths
This is the part that took me a while to figure out, and I haven’t seen it written up anywhere.
The naive approach to generating output schemas is to walk the operation’s selection set and type each field by its GraphQL nullability. A non-null field becomes a required property. data is a required object containing the operation result. This passes every happy-path demo.
Then you turn on the MCP SDK’s validate_tool_call_results: true and call a tool that hits an error path.
Concrete example from the POC: calling rename_product with a non-existent ID makes the resolver raise GraphQL::ExecutionError("Product not found"). GraphQL handles this gracefully, it returns:
{
"data": null,
"errors": [{ "message": "Product not found", "locations": [], "path": ["renameProduct"] }]
}
But the naively generated output schema said data must be a non-null object with a required renameProduct property. Result validation rejects GraphQL’s own response. The MCP client gets an opaque -32603 Internal error instead of “Product not found.”
An LLM agent hits error paths constantly, non-existent IDs, validation failures, permission errors. If every error path produces a cryptic internal error instead of the actual GraphQL error message, you’ve built a demo, not something an agent can actually use.
The fix, and why it’s principled
GraphQL has a null propagation rule: a non-null field that errors nulls its nearest nullable ancestor. When there is no nullable ancestor between a non-null field and the root, the propagation bubbles all the way up to data itself. So data must accept null even when every field you selected is declared non-null.
The positions that need an extra null branch in the output schema are therefore:
- Nullable fields, which the compiler already handles naturally
- The root
dataenvelope, which has no nullable ancestor to absorb propagation
The fix is tiny:
data: { anyOf: [ data_schema, { type: "null" } ] },
errors: { type: "array", items: { type: "object" } },
extensions: { type: "object" }
data becomes anyOf: [<the success shape>, {type: null}], and the envelope allows errors and extensions. After the fix, the error path returns the real GraphQL error envelope, and the tool call succeeds (with isError: false, correct, because a GraphQL field error is a successful tool invocation that returned an error envelope, not a transport failure).
The lesson: GraphQL nullability and MCP output-schema nullability are not the same thing. You have to model error propagation, not just the type system.
What’s Not Here: The Gap to Production
I want to be honest about what this POC is. It runs against a toy in-memory product catalog with two operations. Before I’d package this as a gem and tell anyone to run it in production, I’d want:
Real auth threading. The POC stubs server_context into a boolean flag in the GraphQL context. A real shop needs to pull credentials from the MCP session and build the same authorization context their regular GraphQL requests use, same current user, same role checks, same per-field authorization. That plumbing is application-specific but the POC shows where to thread it (the context: argument to schema.execute).
Relay connections. Most serious graphql-ruby APIs are cursor-paginated everywhere with edges/nodes/pageInfo patterns. Mapping a connection into a bounded tool output an LLM can consume without blowing its context window is an unsolved design question. Do you unwrap the nodes? Do you include pageInfo for multi-call pagination? Do you set a max-items cap in the operation? I don’t have a clean answer yet.
Custom scalar registry. The compiler currently falls back to {type: "string"} for unrecognized scalars. A production scalar registry needs to map Date, DateTime, BigDecimal, JSON to their real JSON Schema equivalents, otherwise you lose precision in both directions and the LLM has no schema guidance for how to format inputs.
Fragments, interfaces, unions. The output compiler raises if it encounters anything other than field selections. Real schemas use inline fragments on union types constantly.
Result-size and token budgeting. Nothing in this POC prevents a ListProducts call from returning 10,000 products and blowing an agent’s context window. This probably wants a combination of operation-level pagination defaults, a result-size limit in the tool wrapper, or a token-count check before returning.
I’m listing these as an invitation, not a disclaimer. The core architecture, operations as the capability unit, generated manifest as the contract, in-process execution against the real schema, feels right. The gaps are engineering work, not design problems.
Summary
If you’re thinking about MCP exposure for a graphql-ruby endpoint:
- Field is the wrong boundary. Operation is the right one.
- Committed
.graphqlfiles are your capability allowlist. Variables become inputs, selection set becomes output, leading comment becomes description. - Commit a generated manifest and gate CI on
mcp:check. Schema drift becomes a reviewable code change, not a runtime surprise. - Make
datanullable in your output schema. GraphQL null propagation means errors can nulldataeven when every field you selected is non-null. The naive generator will break on every error path.
The full POC is at ruby-graphql-mcp-poc. I’d be curious to hear from anyone who’s tackled the Relay connections question or has thoughts on the scalar registry design. Thanks for reading.
