DEV Community

Vipul Dhaigude
Vipul Dhaigude

Posted on

Building a Production MCP Server, End to End

Cover Image

The Model Context Protocol gives an AI agent a clean way to call tools. What it doesn't give you is the tools themselves — and if your backend is a GraphQL API with a few dozen queries and mutations, hand-writing one MCP tool per operation is the kind of task that's tedious the first time and a maintenance liability every time after. Add a field to your schema, remember to add it to the tool. Add a new entity, remember to write three new tools for it. The schema and the tool layer drift apart the moment someone forgets.

The fix is to stop hand-writing them. Introspect the GraphQL schema once, and generate the tools from it.

This post walks through mcp-server-template, a Python MCP server built around that idea, why each piece exists, and what actually happened when I ran it from a clean clone.

Architecture at a glance

System overview

The core idea: tools generated, not written

The server introspects a GraphQL schema into a normalized internal shape — entities, scalar fields, nested relations, whether a query returns a list or a single object, which entities have create/update mutations. A tool factory walks that shape and generates get_<entity>, create_<entity>, and update_<entity> tools per entity, with each tool's JSON schema pulled straight from the API's own field descriptions — so the tools can't quietly drift from the schema the way hand-written ones do.

Nested relations come through dot-notation include paths instead of eager-loading everything: include=["tasks.assignee"] fetches tasks with their assignees, two levels deep, without every call dragging in the full object graph.

The other piece worth calling out is the entity map — a compact, plain-text summary of every entity and its fields, injected into the agent's system prompt:

Project fields: id, name, description, tasks (nested)
Task fields: id, title, status, project, assignee (nested)
Enter fullscreen mode Exit fullscreen mode

Without it, agents tend to call a tool just to discover what fields exist, then call it again with what they actually needed. The map comes from the same normalized schema as the tools, so it can't drift from them either.

Tool generation pipeline

What you get out of the box

  • Two transports, one tool registry. Stdio for local clients like Claude Desktop, HTTP for server-hosted, multi-client setups — both talk to the same tool set; transport is just a thin adapter.
  • Pluggable auth. A small AuthProvider protocol, with static-token (testing) and OIDC (production — Keycloak, Azure AD, Auth0, Cognito) built in.
  • A middleware chain for logging, error normalization, and auth, each independent and composable.
  • An overrides file as an escape hatch — hide a query, override a description, exclude a field from mutations — for schema shapes the generator can't fully infer. If you're reaching for it constantly, the generator needs fixing, not the override file.

It ships with a demo GraphQL schema (a generic project/task-management domain) and a mock backend with fixture data, so the whole stack runs standalone. But the demo schema is there to prove the generator, not to limit it — swap SCHEMA_FILE/GRAPHQL_BASE_URL for your own endpoint and it runs against a real backend with little to no modification.

Three bugs I hit building this — already fixed here

Three failure modes showed up enough while building the systems this template is drawn from to be worth naming — none of them fail loudly, all three already fixed in the template.

  • Array output on a single-object query. is_list lost between introspection and the tool schema crashes downstream, not at generation — fix: thread it through explicitly, test single-object queries specifically.
  • A guard query that silently stops guarding. A swallowed exception from a raw-string auth check turns off a security guard with no error at all — fix: typed queries for anything security-relevant, never swallow an auth-check exception.
  • Schema shapes the generator can't infer. Read-only aggregates and odd filters don't fit get/create/update — fix: the override file skips them instead of forcing a bad fit.

Same pattern underneath all three: nothing throws where the actual bug is. The generation step is invisible once it's done its job, so a bug in it surfaces downstream, disconnected from the cause.

What I actually verified, not just what I claim

None of the above is worth much without checking it runs. From a fresh clone:

pip install -e ".[dev]"
Enter fullscreen mode Exit fullscreen mode

installed clean. Then, without starting anything:

python -m mcp_server_template generate --schema-file demo-schema.graphql
Enter fullscreen mode Exit fullscreen mode

produced a full set of generated tool definitions straight from the demo schema — every entity's get/create/update tools, correctly shaped, before a single server process starts. That command exists specifically so you can inspect what a schema will produce before trusting it in a running system.

Then the full stack, mock backend included:

MOCK_BACKEND=1 python -m mcp_server_template
Enter fullscreen mode Exit fullscreen mode

brought up the mock GraphQL backend and the MCP server alongside it, both shutting down together on exit. Every entity in the demo schema registered correctly — no manual wiring, no tool definitions to keep in sync by hand.

Why this shape, specifically

A few of the design choices are worth calling out because they weren't the only option:

Schema-driven generation over hand-written tools. The obvious alternative is writing each tool by hand, which is more explicit but doesn't scale past a handful of entities and guarantees drift the moment the schema changes and the tools don't.

Transport as a thin adapter. Both stdio and HTTP wrap the same tool registry rather than each having its own logic. Adding a third transport later means writing an adapter, not duplicating the tool layer.

Auth as a protocol, not a provider. The template ships static-token and OIDC implementations, but neither is load-bearing to the architecture — the interface is what matters, and it's small enough that a third implementation is a single class.

Overrides as an escape hatch, not the primary mechanism. The generator is designed to cover the common case well rather than trying to be clever enough to cover everything. The 10% it can't infer gets a deliberate, visible override rather than a generator that silently guesses wrong.

It isn't GraphQL-specific

Nothing about the pipeline — introspect, normalize, generate get/create/update tools, render an entity map — actually depends on GraphQL. Swap schema introspection for an OpenAPI/Swagger JSON schema and the same shape applies to a REST API: parse the spec into the same normalized entity representation, and the tool factory doesn't need to know or care where the schema came from. GraphQL just made a convenient first target because introspection is already structured and machine-readable by design. A REST backend with a well-formed OpenAPI spec gives you the same starting point.

What's next

This is the first of three posts, one per template. Next: the agent side — a LangGraph agent built around a middleware execution engine, with human-in-the-loop approval and tools sourced entirely from a server like this one over MCP, rather than hand-registered.

The template is MIT-licensed and public: mcp-server-template.

Top comments (0)