How to build an MCP server in Python (from one running in production)
Most MCP server tutorials build a weather demo and stop. This one is built around a server I actually run in production: a read-only Shopify connector that's open source, exposes six tools, and rejects every write at the query-parser level before it can touch a store. I'll show you the real code, the parts that matter, and the ones the demos skip.
What an MCP server actually is
An MCP server is a small program that exposes a set of tools an AI model can call. The Model Context Protocol (MCP), introduced by Anthropic in November 2024, is the standard wire format for that exchange. The model says "call this tool with these arguments," your server runs the logic, and it returns a result the model can read. That's the whole idea: it's an adapter between an LLM and some capability, like a Shopify store, a database, or a filesystem.
People overcomplicate this. An MCP server is not a framework, an agent, or a service mesh. It's a process that advertises a list of tools and handles the calls. The protocol agreed on the handshake so any MCP-aware client (Claude Desktop, Claude Code, Cursor, your own agent built on the Claude Agent SDK) can use any server without custom glue. That interoperability is the entire point, and it's why MCP spread fast.
Why Python and FastMCP
Use Python with the official mcp SDK and its FastMCP helper. It turns
an ordinary Python function into an MCP tool with a decorator, generates the JSON schema from
your type hints, and handles the protocol so you write logic, not plumbing.
You can build MCP servers in TypeScript too, and the TS SDK is excellent. I reach for Python
when the tool wraps something Python is already good at: HTTP APIs, data work, anything with a
mature client library. The Shopify server I'll show you is Python because its whole job is making
authenticated GraphQL calls and parsing the responses, which Python's requests and
graphql-core handle cleanly.
You need three dependencies. That's it:
mcp>=1.6.0
requests>=2.32.3
graphql-core>=3.2.3
The minimal server (20 lines)
A working MCP server is one FastMCP instance, one or more functions decorated with
@mcp.tool(), and a mcp.run() call. Here is a complete, runnable server:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("hello")
@mcp.tool()
def greet(name: str) -> str:
"""Return a greeting for the given name."""
return f"Hello, {name}."
if __name__ == "__main__":
mcp.run()
That runs. The docstring becomes the tool's description, the name: str hint becomes
the input schema, and the model now has a greet tool. Real servers are bigger because
the logic is bigger, not because the protocol is. My Shopify server's entry point is the same
two lines at the bottom: mcp = FastMCP("shopify") at the top, mcp.run()
at the end. Everything between is just Shopify logic.
Defining tools properly: titles and annotations
Give every tool a human-readable title and a readOnlyHint or
destructiveHint annotation. These tell the client (and the human approving the call)
what the tool does before it runs. They're also required if you ever want the server listed in
Anthropic's connector directory.
The bare @mcp.tool() from the minimal example works, but it leaves the client guessing
about intent. Here's a real tool from the Shopify server, annotated as read-only so a client knows
it can never mutate store data:
from mcp.types import ToolAnnotations
@mcp.tool(
annotations=ToolAnnotations(
title="Run read-only Shopify GraphQL query",
readOnlyHint=True,
openWorldHint=True,
)
)
def shopify_graphql_query(query: str, shop: str | None = None) -> str:
"""Run an arbitrary read-only Shopify Admin GraphQL query.
Mutations are rejected by the parser before the request is sent."""
_assert_read_only(query) # the safety gate, covered next
return _graphql_call(shop, query)
The whole Shopify server exposes six tools, every one annotated read-only: list stores, run a query, introspect the schema, launch a bulk export, poll a bulk job, and run a ShopifyQL analytics query. A client can look at that list and know, before calling anything, that this connector reads and never writes.
The part demos skip: making a tool safe
If your tool wraps an API that can write, you have to enforce the read-only boundary yourself, in code, before the request goes out. Don't trust the model to only send safe queries, and don't rely on the remote API to reject bad ones. Parse the request and reject anything that isn't a read.
This is the difference between a demo and something you'd let an agent loose on. The Shopify Admin
API speaks GraphQL, where a single string can be a harmless read or a destructive mutation. So the
server parses every query with graphql-core and rejects any operation that isn't a
read, before a single byte hits Shopify:
from graphql import parse, GraphQLSyntaxError
from graphql.language.ast import OperationDefinitionNode
ALLOWED_MUTATIONS = {"bulkOperationCancel"} # cancels a job, writes no store data
def _assert_read_only(query_str: str) -> None:
"""Parse GraphQL and reject non-read-only operations."""
doc = parse(query_str) # raises on invalid GraphQL
for defn in doc.definitions:
if not isinstance(defn, OperationDefinitionNode):
continue
op = defn.operation.value
if op == "subscription":
raise ValueError("Subscriptions are not supported")
if op == "mutation":
for sel in defn.selection_set.selections:
field = getattr(getattr(sel, "name", None), "value", None)
if field not in ALLOWED_MUTATIONS:
raise ValueError(f"Mutation '{field}' is not permitted")
Three things make this hold up. It parses the actual query rather than pattern-matching strings, so you can't sneak a mutation past it with whitespace or comments. It allowlists, so the default for any unknown operation is rejection, not permission. And it runs in the tool function before the HTTP call, so a rejected query never reaches Shopify at all. There's no reliance on Shopify's own validation as the safety net, because by the time Shopify would reject it, you've already trusted the model's input.
Testing without a client
You don't need Claude running to test an MCP server. Import the module and call the underlying functions directly, or list the registered tools programmatically. The safety logic in particular should have unit tests that don't touch the network.
The most valuable tests for the Shopify server cover the read-only gate, and they need no Shopify
credentials. They feed real queries and mutations to _assert_read_only and assert the
reads pass and the writes raise:
import pytest, server
def test_read_query_passes():
server._assert_read_only("{ shop { name } }") # no exception = pass
def test_mutation_is_rejected():
with pytest.raises(Exception):
server._assert_read_only('mutation { productCreate(input: {title: "x"}) { product { id } } }')
To confirm the tools registered correctly (and didn't silently fail, which the MCP protocol lets happen), list them:
import asyncio, server
tools = asyncio.run(server.mcp.list_tools())
print(len(tools), [t.name for t in tools])
# 6 ['shopify_list_stores', 'shopify_graphql_query', ...]
That list_tools check matters more than it looks. A malformed tool schema can cause an
MCP server to start cleanly and register zero tools, with no error. Asserting the count in CI catches
it before a client does.
Connecting it to Claude
Point an MCP client at your server's run command. For Claude Desktop or Claude Code, that's an entry
in the MCP config naming the command (python server.py) and any environment variables
the server reads for credentials.
The server reads its Shopify credentials from the environment, so the client config passes them in rather than hardcoding anything:
{
"mcpServers": {
"shopify": {
"command": "python",
"args": ["/path/to/server.py"],
"env": {
"SHOPIFY_DOMAIN": "my-store.myshopify.com",
"SHOPIFY_ACCESS_TOKEN": "shpat_..."
}
}
}
}
Restart the client, and the six tools appear. From there the model can ask the store questions in plain language, and every answer it gives is backed by a real, read-only GraphQL call it made itself.
Five mistakes I made so you don't have to
- Trusting the model to stay read-only. It mostly will. "Mostly" is not a security model. Enforce it in code, with a deny default.
- Skipping tool annotations. Without
titleandreadOnlyHint, clients can't tell a human what a call does, and the connector directory won't take it. - Not asserting the tool count. A bad schema registers zero tools silently. One CI assertion saves an afternoon.
- Hardcoding credentials. Read them from the environment so the client passes them in. Secrets in source is how secrets leak.
- Letting errors leak the token. Redact access tokens from logs and error messages. An error string that prints your
shpat_token is a credential in your log aggregator.
None of these show up in a weather-demo tutorial because a weather demo has no credentials, no writes, and no consequences. A server an agent uses against a real store has all three. The protocol is the easy part. The boundary is the work.
The full Shopify server, including the throttle handling, multi-store support, and the tests, is open source. If you're building one of your own, fork it as a starting point rather than a weather demo: it's the same shape, with the safety actually built in.
Pavle Lazic is the founder of Scalably, where he builds and runs multi-tenant Claude agent platforms in production for real businesses. He writes about the Claude Agent SDK, MCP servers, and what it actually takes to put AI agents to work. See the platform.