An MCP server is a small program that exposes Tools, Resources, and Prompts to any AI client over JSON-RPC 2.0. You build one by instantiating a server object, registering each capability with a name, an input schema, and a handler function, then connecting a transport — stdio for local use, Streamable HTTP for remote. The credentials and privileged work stay inside your server process; the model only ever sees the declared schema and the structured output you return. This class builds a complete server in Node.js, ports it to Python, and registers it in both Claude Code and Antigravity.
The 60-Second TL;DR
5 Strategic Takeaways
- MCP is client-agnostic. One correctly built server works in Claude Code, Antigravity, Cursor, and any other compliant host. The server code is identical; only the registration step differs per client.
- The primitive you choose encodes intent. Side effects mean Tool. Read-only data means Resource. Reusable instruction template means Prompt. Getting this right makes the model use your server correctly without extra prompting.
- The server is your security boundary. Credentials, database connections, and privileged operations live inside the server process. The model sees only the input schema and the structured output. Design the boundary deliberately.
- Transport is a deployment decision, not a code decision. The same tool handlers run over stdio or Streamable HTTP. Pick stdio for local personal tooling and Streamable HTTP for shared remote servers. Do not over-engineer a local tool into a web service.
- Short, well-described tool lists beat large ones. A handful of clearly named tools with good descriptions keeps the agent oriented. Tool descriptions are how the model selects them, so write them for the model, not for yourself.
Why This Class Exists
Most MCP tutorials stop at a toy calculator that adds two numbers. That teaches the API surface but not the judgment: which primitive, which transport, how to handle credentials, how to register the result in a real client, and how to debug it when the handshake silently fails. This class builds something you would actually deploy and walks every decision.
A server you would actually run
The build is a Shopify-orders MCP server with three tools backed by a local SQLite mirror. It is directly useful for commerce work and concrete enough to fork for your own data.
Node.js first, Python second
The primary build is Node.js with TypeScript and the official SDK. The same server is then ported to Python with FastMCP, so you can match whichever stack your team already runs.
Claude Code and Antigravity
Registration is shown for both hosts with the exact commands and config files, including the environment-variable credential pattern that keeps secrets out of your config on disk.
Current to May 2026
Every API call is checked against the current TypeScript SDK, FastMCP 3.x, and the official Claude Code MCP documentation. No stale signatures, no invented flags.
What Is the Model Context Protocol?
The Model Context Protocol is an open standard introduced by Anthropic in November 2024. It standardizes how AI applications connect to external tools, data sources, and services. The canonical metaphor is a USB-C port for AI: any compliant host — Claude, Claude Code, Antigravity, Cursor, VS Code Copilot — can plug into any compliant server and immediately discover and use its capabilities, with no custom per-integration code.
Before MCP, every AI-to-tool connection was bespoke. If you wanted Claude to read your database and Cursor to read the same database, you wrote two different integrations. MCP collapses that into one: you build a server once, and every compliant client can use it. That single property is why the ecosystem grew from an obscure spec to the de facto standard in eighteen months, with SDK downloads up roughly 970-fold and over 2,000 community servers in the registry by early 2026.
An MCP server is not an AI. It is a plain program — Node, Python, Go, anything — that speaks a specific JSON protocol. The intelligence lives in the client (the model). Your server just exposes capabilities and executes them when asked. If you can write a function, you can write an MCP tool.
The Client-Server Model
MCP has three roles. Keep them distinct in your head and the rest of the protocol follows.
| Role | What it is | Example |
|---|---|---|
| Host | The AI application the user interacts with. It coordinates one or more clients and runs the model. | Claude Code, Antigravity, Claude Desktop |
| Client | Lives inside the host. Manages the connection to one server: discovers its capabilities, sends requests, receives results. | The MCP client embedded in Claude Code |
| Server | The program you build. Exposes Tools, Resources, and Prompts and executes them on request. | Your Shopify-orders server |
One host can run many clients, each connected to a different server. When you register five MCP servers in Claude Code, the host spins up five clients, each handshaking with its own server. The model sees the union of all their capabilities as one combined toolset.
The connection lifecycle is always the same: the client sends an initialize request, the server responds with its protocol version and capabilities, the client asks for the capability lists (tools/list, resources/list, prompts/list), and from then on the client calls into the server as the model decides. That handshake is exactly what the loading animation at the top of this page depicts.
The Three Primitives
Everything an MCP server exposes is one of three things. This is the single most important concept in the protocol, because choosing the wrong primitive is the most common design mistake.
| Primitive | Control | Has side effects? | Use it for |
|---|---|---|---|
| Tool | Model-controlled | Yes — may compute, write, call APIs | Actions: run a query, create a record, send a request |
| Resource | Application-controlled | No — read-only | Data: a document, a schema, a file the model can reference |
| Prompt | User-controlled | No — a template | Reusable parameterized instructions, often surfaced as slash commands |
The rule of thumb that resolves almost every case: if it has side effects, it is a Tool; if it is read-only, it is a Resource. A function that filters and computes over your orders is a Tool. A static price list the model can pull at any time is a Resource. An instruction template like "summarize this customer in three bullets" is a Prompt.
// Forces the model to "call" something
// that has no side effects — wasteful
registerTool("get_return_policy",
{ description: "Return the policy text" },
async () => ({ content: [{
type: "text",
text: POLICY_STRING
}]})
);
// The host can surface this to the model
// at any time, no tool call required
registerResource("return-policy",
"policy://returns",
{ title: "Return Policy",
description: "DDS return policy" },
async (uri) => ({ contents: [{
uri: uri.href, text: POLICY_STRING
}]})
);
Transports and JSON-RPC 2.0
Every MCP message is a JSON-RPC 2.0 message. JSON-RPC 2.0 is a lightweight remote-procedure-call protocol: a request names a method and carries parameters, the response carries a result or an error, and notifications are one-way messages with no reply. MCP uses it because it gives a standard, language-neutral structure for requests, responses, and notifications.
You almost never write JSON-RPC by hand — the SDK serializes it for you. But knowing the wire format is what lets you debug a server when the handshake fails. A tools/call request looks like this on the wire:
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "get_order_detail",
"arguments": { "orderId": "1042" }
}
}
The transport is how those JSON-RPC messages move between client and server. There are two you will actually use, plus one legacy option.
| Transport | When | Status |
|---|---|---|
| stdio | Local server run as a child process of the client. Messages flow over stdin/stdout. The default for personal tooling and the focus of this class. | Current, recommended for local |
| Streamable HTTP | Remote server reachable over the network. The modern, fully featured transport for shared and cloud-hosted servers. | Current, recommended for remote |
| HTTP + SSE | The original remote transport. Superseded by Streamable HTTP. | Deprecated — backwards compatibility only |
Your tool handlers do not change between transports. You write the logic once, then connect a StdioServerTransport for local use or a StreamableHTTPServerTransport for remote. Do not turn a local tool into a web service unless multiple machines need it.
The Spec Timeline
MCP is versioned by date. Knowing where the spec is helps you read older tutorials critically — an example written against the 2024 spec may use patterns that no longer apply.
| Version | Released | Headline changes |
|---|---|---|
| 2024-11-05 | Nov 2024 | Initial release. Tools, Resources, Prompts, stdio + HTTP/SSE. |
| 2025-03-26 | Mar 2025 | OAuth 2.1 authorization, Streamable HTTP transport, tool annotations. |
| 2025-06-18 | Jun 2025 | Structured tool outputs, elicitation, resource indicators. JSON-RPC batching removed. |
| 2025-11-25 | Nov 2025 | Current stable. Tasks primitive, URL-mode elicitation, sampling with tools. |
| 2026-07-28 | RC May 2026 | Release candidate — largest revision yet. Stateless core, Extensions framework, Tasks, MCP Apps, auth hardening, formal deprecation policy. Final publishes Jul 28, 2026. |
This class targets the current stable 2025-11-25 spec, which is what the shipping SDKs implement as of May 2026. The 2026-07-28 release candidate was announced on May 21, 2026 — a week before this class — and its stateless-core direction is worth understanding, but you build against the stable spec until the final publishes and SDKs catch up.
The 8 Architectural Decisions
Make these eight decisions before you write code. Each one has a default that is right most of the time and a failure mode that bites you if you choose wrong.
Decision 1 — Transport: stdio or Streamable HTTP
stdio runs your server as a child process of one client on one machine. Streamable HTTP runs it as a network service many clients can reach.
Tradeoff: stdio is trivially simple and needs no auth, networking, or deployment — but it only serves the local machine. Streamable HTTP serves everyone but adds auth, hosting, and a larger attack surface.
Failure mode: building a remote HTTP server for a tool only you use. You inherit OAuth flows, TLS, and uptime concerns for zero benefit.
Recommendation: stdio unless multiple machines or teammates genuinely need the same server. This class builds stdio.
Decision 2 — Stateless or stateful
A stateless server treats every request independently. A stateful server tracks per-session context behind a session ID.
Tradeoff: stateless scales on ordinary HTTP infrastructure and is simpler to reason about. Stateful enables resumability and features that depend on session memory, at the cost of session storage and affinity.
Failure mode: holding state in module-level variables in what you assumed was a stateless server, then deploying multiple instances behind a load balancer. Requests hit different instances and the state is inconsistent.
Recommendation: stateless by default. stdio servers are inherently single-session, so this only matters for remote servers. The 2026-07-28 spec direction is explicitly stateless-first.
Decision 3 — Tool granularity
Do you expose one tool that does many things via a mode parameter, or many narrow tools?
Tradeoff: few coarse tools keep the list short but push complexity into parameters the model must get right. Many fine tools are each obvious but a long list dilutes the model's attention.
Failure mode: a single do_everything tool with a mode enum and fifteen optional parameters. The model routinely picks the wrong combination.
Recommendation: one tool per distinct user intent. This build has exactly three because there are three things a user asks about orders: list them, inspect one, summarize a customer's history.
Decision 4 — Error handling
When a tool fails, do you throw an exception or return an error in the tool result?
Tradeoff: throwing surfaces a protocol-level error that the host may treat as a hard failure. Returning an error inside the tool result lets the model read it and recover — retry, ask the user, or try a different approach.
Failure mode: throwing on a missing record. The model gets an opaque protocol error instead of "no order found with that ID," and cannot recover gracefully.
Recommendation: return expected failures (not found, invalid input) as readable error content with isError: true. Reserve thrown exceptions for genuine bugs the model cannot act on.
Decision 5 — Credentials and auth
How does the server get its database password or API token, and how is it protected?
Tradeoff: inlining a token in the config is the fastest path and the worst one — it persists the secret to disk in clear text. Environment variables add one setup step and remove the secret from any committed file.
Failure mode: committing an mcp_config.json with a live token in the args array. Anyone with repo access — or read access to your home directory — now has the credential.
Recommendation: reference environment variables in the config, set the real value in your shell profile or the host's env block, and rotate anything that has ever been written in plain text. Covered in detail in the credential pattern section.
Decision 6 — Least privilege at the boundary
What can the server actually do, beyond what the model asks?
Tradeoff: a broadly privileged server is easy to build and dangerous. A least-privileged server takes more setup — a read-only database user, a scoped file path — but limits blast radius if the model is manipulated via prompt injection.
Failure mode: giving an analytics tool a database connection with write and delete rights. A prompt-injection attack can now mutate production data through your read-only-looking tool.
Recommendation: grant the server the minimum it needs. This build connects to SQLite read-only and never writes. If a tool only reads, its credentials should only read.
Decision 7 — Schema design
How precisely do you declare tool inputs and outputs?
Tradeoff: loose schemas (a single free-text string) are quick but let the model send malformed input. Tight schemas (typed fields with constraints) validate automatically and document the tool for the model, at the cost of a few more lines.
Failure mode: an input schema of { query: string } for a tool that actually needs a structured filter. The model stuffs everything into one string and your handler has to parse free text.
Recommendation: declare specific typed input fields, and declare an output schema for structured results so the model gets typed data back, not just a text blob. In Node this is Zod; in Python it is type hints.
Decision 8 — Versioning and evolution
How will you change the server without breaking the clients that depend on it?
Tradeoff: renaming or removing a tool is clean but breaks every workflow that referenced the old name. Additive change is safe but accumulates surface area over time.
Failure mode: renaming get_order to get_order_detail in a shared server. Every teammate's saved prompt referencing the old name silently stops working.
Recommendation: treat tool names as a public API. Add new tools rather than renaming; if you must deprecate, keep the old name working for a transition window. The 2026-07-28 spec adds a formal deprecation policy for exactly this reason.
Six of these eight decisions reduce to one principle: the server is a security and stability boundary, so design it deliberately. The model is powerful and occasionally manipulable. Your server decides what it can actually do. Build the boundary on purpose.
Build It in Node.js
We are building a Shopify-orders MCP server. It reads a local SQLite mirror of order data and exposes three tools. Nothing here is a stub — every file is complete and runnable. The server connects over stdio, which is what Claude Code and Antigravity spawn for local servers.
Why a local SQLite mirror instead of calling the Shopify Admin API live? Two reasons. First, it keeps the class self-contained — you can run it with no Shopify credentials. Second, it demonstrates the least-privilege principle: the server reads a local read-only database and never touches production. In a real deployment you would point the same handlers at your live data source.
Project layout
shopify-orders-mcp/
├── package.json
├── tsconfig.json
├── seed.mjs # one-time: builds and fills orders.db
├── orders.db # generated by seed.mjs
└── src/
└── index.ts # the MCP server
package.json
{
"name": "shopify-orders-mcp",
"version": "1.0.0",
"description": "MCP server exposing read-only Shopify order tools",
"type": "module",
"bin": { "shopify-orders-mcp": "dist/index.js" },
"scripts": {
"build": "tsc",
"seed": "node seed.mjs",
"start": "node dist/index.js",
"dev": "tsc --watch"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"better-sqlite3": "^11.8.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.0",
"@types/node": "^22.0.0",
"typescript": "^5.7.0"
}
}
tsconfig.json
The module settings are precise. Node16 for both module and moduleResolution is required so the SDK's .js extension imports resolve correctly.
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
seed.mjs — build the sample database
Run this once with npm run seed. It creates orders.db and fills it with sample order data so the server has something to read. In production you would replace this with a sync from your real source.
import Database from "better-sqlite3";
const db = new Database("orders.db");
db.exec(`
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
id TEXT PRIMARY KEY,
customer TEXT NOT NULL,
email TEXT NOT NULL,
total_cents INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'USD',
status TEXT NOT NULL,
created_at TEXT NOT NULL
);
`);
const sample = [
["1042", "Ada Lovelace", "ada@example.com", 8900, "USD", "fulfilled", "2026-05-20T14:02:00Z"],
["1043", "Alan Turing", "alan@example.com", 12400, "USD", "fulfilled", "2026-05-21T09:15:00Z"],
["1044", "Ada Lovelace", "ada@example.com", 4500, "USD", "cancelled", "2026-05-22T11:40:00Z"],
["1045", "Grace Hopper", "grace@example.com", 23900, "USD", "pending", "2026-05-24T16:55:00Z"],
["1046", "Alan Turing", "alan@example.com", 6700, "USD", "fulfilled", "2026-05-25T08:05:00Z"]
];
const insert = db.prepare(
"INSERT INTO orders (id, customer, email, total_cents, currency, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
);
const tx = db.transaction((rows) => { for (const r of rows) insert.run(...r); });
tx(sample);
console.error(`Seeded ${sample.length} orders into orders.db`);
db.close();
The Three Tools
Here is the complete server: src/index.ts. It opens the database read-only, registers three tools with typed input and output schemas, and connects a stdio transport. Read the comments — every architectural decision from the earlier section shows up here.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import Database from "better-sqlite3";
// Decision 6: least privilege. Open the database READ-ONLY.
// Even if a tool is manipulated, it physically cannot write.
const db = new Database("orders.db", { readonly: true, fileMustExist: true });
// A row shape we reuse in handlers.
type OrderRow = {
id: string;
customer: string;
email: string;
total_cents: number;
currency: string;
status: string;
created_at: string;
};
function formatMoney(cents: number, currency: string): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(cents / 100);
}
const server = new McpServer({ name: "shopify-orders-mcp", version: "1.0.0" });
// ---- Tool 1: list_recent_orders ----------------------------------------
// Decision 3: one tool per intent. This one answers "what came in lately".
server.registerTool(
"list_recent_orders",
{
title: "List Recent Orders",
description:
"List the most recent orders, newest first. Use when the user asks what orders came in recently or wants an overview of latest sales. Returns id, customer, total, status, and date.",
inputSchema: {
limit: z.number().int().min(1).max(50).default(10)
.describe("How many orders to return, newest first."),
status: z.enum(["fulfilled", "pending", "cancelled"]).optional()
.describe("Optional filter to a single order status.")
},
outputSchema: {
orders: z.array(z.object({
id: z.string(),
customer: z.string(),
total: z.string(),
status: z.string(),
createdAt: z.string()
})),
count: z.number()
}
},
async ({ limit, status }) => {
const rows = (status
? db.prepare("SELECT * FROM orders WHERE status = ? ORDER BY created_at DESC LIMIT ?").all(status, limit)
: db.prepare("SELECT * FROM orders ORDER BY created_at DESC LIMIT ?").all(limit)
) as OrderRow[];
const orders = rows.map((r) => ({
id: r.id,
customer: r.customer,
total: formatMoney(r.total_cents, r.currency),
status: r.status,
createdAt: r.created_at
}));
const out = { orders, count: orders.length };
return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }], structuredContent: out };
}
);
// ---- Tool 2: get_order_detail ------------------------------------------
// Decision 4: return expected failures as readable error content,
// do not throw, so the model can recover.
server.registerTool(
"get_order_detail",
{
title: "Get Order Detail",
description:
"Look up a single order by its ID and return full detail. Use when the user references a specific order number.",
inputSchema: {
orderId: z.string().min(1).describe("The order ID, e.g. '1042'.")
},
outputSchema: {
id: z.string(),
customer: z.string(),
email: z.string(),
total: z.string(),
status: z.string(),
createdAt: z.string()
}
},
async ({ orderId }) => {
const row = db.prepare("SELECT * FROM orders WHERE id = ?").get(orderId) as OrderRow | undefined;
if (!row) {
return {
content: [{ type: "text", text: `No order found with ID ${orderId}.` }],
isError: true
};
}
const out = {
id: row.id,
customer: row.customer,
email: row.email,
total: formatMoney(row.total_cents, row.currency),
status: row.status,
createdAt: row.created_at
};
return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }], structuredContent: out };
}
);
// ---- Tool 3: summarize_customer_history --------------------------------
// Decision 7: structured output. The model gets typed aggregates back,
// not a sentence it has to parse.
server.registerTool(
"summarize_customer_history",
{
title: "Summarize Customer History",
description:
"Summarize one customer's order history by email: order count, total spent, and a status breakdown. Use for customer-level questions.",
inputSchema: {
email: z.string().email().describe("The customer's email address.")
},
outputSchema: {
email: z.string(),
orderCount: z.number(),
totalSpent: z.string(),
byStatus: z.record(z.string(), z.number())
}
},
async ({ email }) => {
const rows = db.prepare("SELECT * FROM orders WHERE email = ?").all(email) as OrderRow[];
if (rows.length === 0) {
return {
content: [{ type: "text", text: `No orders found for ${email}.` }],
isError: true
};
}
const totalCents = rows.reduce((sum, r) => sum + r.total_cents, 0);
const byStatus: Record<string, number> = {};
for (const r of rows) byStatus[r.status] = (byStatus[r.status] ?? 0) + 1;
const out = {
email,
orderCount: rows.length,
totalSpent: formatMoney(totalCents, rows[0].currency),
byStatus
};
return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }], structuredContent: out };
}
);
// ---- Connect over stdio -------------------------------------------------
// Decision 1: stdio transport for a local server.
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
// Log to stderr, never stdout — stdout is the JSON-RPC channel.
console.error("shopify-orders-mcp running on stdio");
}
main().catch((err) => {
console.error("Fatal:", err);
process.exit(1);
});
For a stdio server, stdout is the JSON-RPC channel. Anything you print to stdout with console.log corrupts the protocol stream and the client fails to parse messages. Always log to stderr with console.error. This single mistake accounts for most "my server connects but no tools appear" reports.
Run and Connect
Build, seed, and smoke-test before you hand it to a client.
# 1. Install dependencies
npm install
# 2. Compile TypeScript to dist/
npm run build
# 3. Create and fill orders.db (one time)
npm run seed
# → "Seeded 5 orders into orders.db" on stderr
# 4. Smoke test: run the server directly.
# It should print the stderr banner and then wait.
node dist/index.js
# → "shopify-orders-mcp running on stdio"
# (Ctrl+C to stop — it is waiting for a client on stdin.)
If step 4 prints the banner and hangs, the server is healthy — it is waiting for a client to speak JSON-RPC over stdin. If it errors, fix that before registering it anywhere; a server that fails standalone will never connect to a host.
Note the absolute path to dist/index.js — you will need it for registration. From the project root:
# macOS / Linux
echo "$(pwd)/dist/index.js"
# Windows (PowerShell)
Write-Output "$(Get-Location)\dist\index.js"
The Python Port
The same server in Python with FastMCP. Where the Node SDK uses Zod and explicit registerTool calls, FastMCP uses decorators and type hints — the function signature is the schema. Install with pip install "mcp[cli]" or uv add "mcp[cli]"; the cli extra includes the development server and Inspector integration. Python 3.10 or later.
This is the complete server.py. It is functionally identical to the Node build: read-only SQLite, three tools, structured returns, readable errors.
from mcp.server.fastmcp import FastMCP
import sqlite3
# Decision 6: least privilege. Open the database read-only via URI mode.
conn = sqlite3.connect("file:orders.db?mode=ro", uri=True, check_same_thread=False)
conn.row_factory = sqlite3.Row
mcp = FastMCP("shopify-orders-mcp")
def format_money(cents: int, currency: str) -> str:
return f"{cents / 100:,.2f} {currency}"
# ---- Tool 1: list_recent_orders ----------------------------------------
@mcp.tool()
def list_recent_orders(limit: int = 10, status: str | None = None) -> dict:
"""List the most recent orders, newest first.
Use when the user asks what orders came in recently or wants an
overview of latest sales.
Args:
limit: How many orders to return, newest first (1-50).
status: Optional filter: 'fulfilled', 'pending', or 'cancelled'.
"""
limit = max(1, min(50, limit))
if status:
rows = conn.execute(
"SELECT * FROM orders WHERE status = ? ORDER BY created_at DESC LIMIT ?",
(status, limit),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM orders ORDER BY created_at DESC LIMIT ?",
(limit,),
).fetchall()
orders = [
{
"id": r["id"],
"customer": r["customer"],
"total": format_money(r["total_cents"], r["currency"]),
"status": r["status"],
"createdAt": r["created_at"],
}
for r in rows
]
return {"orders": orders, "count": len(orders)}
# ---- Tool 2: get_order_detail ------------------------------------------
@mcp.tool()
def get_order_detail(order_id: str) -> dict:
"""Look up a single order by its ID and return full detail.
Args:
order_id: The order ID, e.g. '1042'.
"""
row = conn.execute("SELECT * FROM orders WHERE id = ?", (order_id,)).fetchone()
if row is None:
# Decision 4: readable error, not an exception.
return {"error": f"No order found with ID {order_id}."}
return {
"id": row["id"],
"customer": row["customer"],
"email": row["email"],
"total": format_money(row["total_cents"], row["currency"]),
"status": row["status"],
"createdAt": row["created_at"],
}
# ---- Tool 3: summarize_customer_history --------------------------------
@mcp.tool()
def summarize_customer_history(email: str) -> dict:
"""Summarize one customer's order history by email.
Returns order count, total spent, and a status breakdown.
Args:
email: The customer's email address.
"""
rows = conn.execute("SELECT * FROM orders WHERE email = ?", (email,)).fetchall()
if not rows:
return {"error": f"No orders found for {email}."}
total_cents = sum(r["total_cents"] for r in rows)
by_status: dict[str, int] = {}
for r in rows:
by_status[r["status"]] = by_status.get(r["status"], 0) + 1
return {
"email": email,
"orderCount": len(rows),
"totalSpent": format_money(total_cents, rows[0]["currency"]),
"byStatus": by_status,
}
if __name__ == "__main__":
# Defaults to stdio transport.
mcp.run()
Run it with python server.py. FastMCP handles the JSON-RPC framing, the handshake, and schema generation from your type hints. The same orders.db from the Node seed works unchanged.
FastMCP derives the input schema from your function signature and the tool description from the docstring. You write a normal typed Python function and the framework produces the spec-compliant tool definition. The Node SDK is more explicit — you hand it the Zod schema directly — which some teams prefer for clarity. Same protocol, same result, different ergonomics.
Register in Claude Code
Claude Code manages MCP servers with the claude mcp command. The critical syntax rule: all flags come before the server name, then a -- separates the name from the command Claude Code will run.
# Add the Node server as a local stdio server (default scope)
claude mcp add --transport stdio shopify-orders \
-- node /absolute/path/to/shopify-orders-mcp/dist/index.js
# Or the Python server
claude mcp add --transport stdio shopify-orders \
-- python /absolute/path/to/shopify-orders-mcp/server.py
Read that as: add a server named shopify-orders, transport stdio, and to run it execute everything after the --. Flags like --transport and --env must precede the name or they get passed to your server instead of to Claude Code.
The three scopes
The --scope flag controls where the registration is stored and who sees it.
| Scope | Loads in | Shared with team? | Stored in |
|---|---|---|---|
| local (default) | Current project only | No — private to you | ~/.claude.json |
| project | Current project only | Yes — via committed file | .mcp.json in project root |
| user | All your projects | No — private to you | ~/.claude.json |
For a server your whole team should get, use project scope so the config is committed:
claude mcp add --transport stdio --scope project shopify-orders \
-- node ./dist/index.js
This writes a .mcp.json at the project root in the standardized format:
{
"mcpServers": {
"shopify-orders": {
"command": "node",
"args": ["./dist/index.js"],
"env": {}
}
}
}
Managing servers
claude mcp list # list all configured servers
claude mcp get shopify-orders # details for one server
claude mcp remove shopify-orders
# Inside a Claude Code session:
/mcp # status, tool counts, OAuth re-auth
The /mcp panel shows the tool count next to each connected server and flags any server that advertises tools but exposes none — a useful signal that your server connected but failed to register its tools.
For security, Claude Code prompts for approval before using project-scoped servers from a .mcp.json file someone else may have committed. If you need to reset those approval choices, run claude mcp reset-project-choices.
Register in Antigravity
Antigravity reads MCP registrations from an mcp_config.json file. The structure is the same shape as Claude Code's .mcp.json: a single mcpServers object whose children are named registrations with a command and an args array.
{
"mcpServers": {
"shopify-orders": {
"command": "node",
"args": ["C:\\path\\to\\shopify-orders-mcp\\dist\\index.js"]
}
}
}
Add your server as a new key alongside any existing ones, save the file, and restart Antigravity — MCP registrations load at application start. A real working Antigravity config commonly runs six to twelve servers this way; the shape never changes, only the number of entries.
Because the registration format is portable, the same server binary is referenced by both clients. You build the server once. Claude Code points at it with claude mcp add or a .mcp.json; Antigravity points at it with mcp_config.json. The server has no idea which client connected — that is the entire promise of a client-agnostic protocol.
The Credential Pattern
Our sample server reads a local file and needs no secrets. The moment your server talks to a real API — the live Shopify Admin API, a remote database — it needs a credential. How you supply it is a security decision, not a convenience one.
The fastest path is also the most dangerous one:
"shopify": {
"command": "node",
"args": [
"/path/to/server.js",
"--accessToken=shpat_REAL_TOKEN_HERE",
"--domain=mystore.myshopify.com"
]
}
That token is now in clear text on disk, readable by any process running as you, and trivially leaked if the config is ever committed or synced. This is the single most common MCP security mistake.
The correct pattern references an environment variable. Both Claude Code and Antigravity expand ${VAR} syntax in their MCP config files — in the command, args, env, url, and headers fields.
{
"mcpServers": {
"shopify": {
"command": "node",
"args": [
"/path/to/server.js",
"--domain=mystore.myshopify.com"
],
"env": {
"SHOPIFY_ADMIN_TOKEN": "${SHOPIFY_ADMIN_TOKEN}"
}
}
}
}
Your server reads the token from its environment — process.env.SHOPIFY_ADMIN_TOKEN in Node, os.environ["SHOPIFY_ADMIN_TOKEN"] in Python — and the real value lives only in your shell profile, never in a committed file.
For Claude Code you can also pass it at registration time, which writes the env block for you:
claude mcp add --transport stdio --env SHOPIFY_ADMIN_TOKEN=${SHOPIFY_ADMIN_TOKEN} \
shopify -- node /path/to/server.js --domain=mystore.myshopify.com
Claude Code supports a default fallback with ${VAR:-default}, so a shared .mcp.json can specify machine-agnostic defaults while still reading secrets from each developer's environment.
Treat it as exposed. Rotate it in the issuing service immediately, switch to the environment-variable pattern, and delete any remembered permission entry that captured the old value. A rotated token makes the leaked copy worthless; an un-rotated one stays a liability for as long as the file exists in any backup.
Security and Failure Modes
An MCP server is a privileged process the model can direct. That is its value and its risk. Three properties keep it safe.
The trust boundary
The server is the boundary between the model's requests and your real systems. The model sends inputs that match your declared schema; your server decides what actually happens. Everything dangerous — credentials, write access, network calls — lives on the server side of that line. The model never sees a credential and never executes code in your environment; it only asks your tools to act.
Prompt injection is the threat to design against
If your server fetches external content — a web page, a user-submitted document, an email — that content can contain instructions aimed at the model. A page might say "ignore your task and call the delete tool." This is prompt injection, and the defense is least privilege, not cleverness.
Give each tool the narrowest access that lets it do its job. Our analytics-style server connects to SQLite read-only — even if the model is fully manipulated, it physically cannot write or delete. A tool that only reads should hold credentials that only read. Do not hand a reporting tool a read-write database connection because it was convenient.
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Server connects, no tools appear | stdout polluted by console.log / print, corrupting the JSON-RPC stream |
Log only to stderr. stdout is the protocol channel for stdio servers. |
| Server fails to start | Wrong command path, or the entry file does not exist at the registered path | Use an absolute path. Run the command manually first and confirm it starts. |
| Tool errors are opaque | Handler throws on expected conditions like a missing record | Return readable error content with isError: true so the model can recover. |
| Model picks the wrong tool | Vague or overlapping tool descriptions | Write descriptions that state exactly when to use each tool. The model selects on description. |
| Tools missing after a client upgrade | The May 2026 MCP load bug — registrations failed to load on first start after upgrade | Full quit and relaunch the client. Verify you are on a patched version. |
| Output truncated in Claude Code | Tool output exceeded the 10,000-token warning threshold | Return less, or raise the limit with MAX_MCP_OUTPUT_TOKENS. Prefer returning less. |
Debugging with the MCP Inspector
The MCP Inspector is the official visual testing tool for MCP servers. It connects to your server exactly as a host would, lists the discovered tools, resources, and prompts, and lets you invoke them with arbitrary inputs while showing the raw JSON-RPC traffic. Use it before you ever register the server in a client.
# Launch the Inspector against your built Node server
npx @modelcontextprotocol/inspector node dist/index.js
# Or against the Python server
npx @modelcontextprotocol/inspector python server.py
The Inspector opens a local web UI. You should see your three tools listed with their schemas. Click list_recent_orders, set limit to 3, and run it — you should get three orders back as structured JSON. If the tool list is empty, your server connected but failed to register tools, which almost always traces back to a stdout write or a registration error visible on stderr.
The debugging ladder
Work these in order. Each rung eliminates a class of problem.
- Run the server standalone.
node dist/index.jsshould print its stderr banner and wait. If it errors here, nothing downstream will work. - Open it in the Inspector. Confirm the tools list populates and each tool runs with sample input. This isolates server bugs from client-config bugs.
- Register in the client and check
/mcp. If the Inspector worked but the client shows no tools, the problem is the registration — wrong path, wrong flags, or a load bug needing a relaunch. - Read the transcript logs. For Antigravity, the per-conversation history under
brain/<uuid>/.system_generated/logs/transcript.jsonlsurfaces MCP load errors. For Claude Code, the/mcppanel and stderr show connection failures.
The Inspector removes the client as a variable. If your server works in the Inspector but not in Claude Code or Antigravity, you know with certainty the problem is registration or client state, not your server code. That single split saves hours.
Architecture Reference: A Real Multi-Server Setup
For perspective on where a single server fits, here is a real production-grade MCP configuration: eight servers registered in one working Antigravity install, spanning three categories. Yours will look different, but the shape — a focused set of servers each owning one domain — is the pattern to aim for.
| Server | Category | Domain it owns |
|---|---|---|
cloudrun | Cloud infra | Deploy and manage Google Cloud Run services |
firebase-mcp-server | Cloud infra | Firebase projects, Firestore queries, hosting deploys |
alloydb-postgres-admin | Database | Manage AlloyDB Postgres instances and run queries |
shopify | Commerce | Shopify Admin GraphQL — products, orders, customers |
shopify-dev-mcp | Commerce docs | Shopify.dev documentation and API reference search |
genkit-mcp-server | AI tooling | Genkit framework flow orchestration |
notebooks | Data analysis | Jupyter notebook execution and BigQuery integration |
visualization | Data analysis | Chart generation and data visualization |
Notice what this configuration does not do: it does not pile twenty overlapping tools into one server. Each server owns a clear domain, and the host presents the union of their capabilities as one toolset. When you build the Shopify-orders server from this class, it slots into a setup like this as a ninth focused server — not as another mode bolted onto an existing one.
Every one of those eight is a stdio server launched with node pointing at an entry-point file, registered in mcp_config.json exactly as you registered yours. The reference setup is not exotic; it is the same pattern repeated eight times with discipline.
Closing Notes
You now have a complete, runnable MCP server in two languages and the judgment to extend it. The compressed playbook for your next server:
- Pick the primitive before the code. Side effects mean Tool, read-only means Resource, reusable instructions mean Prompt. This one decision determines how the model uses your server.
- Default to stdio. A local server is simpler, safer, and sufficient for almost every personal and team-internal tool. Reach for Streamable HTTP only when multiple machines genuinely need the same server.
- Make the server a real boundary. Read-only credentials for read-only tools. Least privilege contains prompt injection. The model is powerful and occasionally manipulable; the server decides what it can actually do.
- Return errors, do not throw them. Readable error content with
isError: truelets the model recover. Reserve exceptions for genuine bugs. - Keep secrets in the environment. Never inline a token in a config file. Reference
${VAR}and set the value in your shell. Rotate anything that ever touched a file in plain text. - Inspector first, client second. Prove the server in the MCP Inspector before you register it anywhere. It removes the client as a variable and isolates server bugs from config bugs.
- Treat tool names as a public API. Add rather than rename. A renamed tool silently breaks every saved workflow that referenced the old name.
The first server is the expensive one — you are learning the protocol, the SDK, the registration flow, and the debugging ladder all at once. The second server reuses the entire scaffold: same package.json shape, same transport wiring, same registration commands, same Inspector workflow. You only write new tool handlers. The protocol fades into the background and you are left building capabilities. Pay the cost once; collect the dividend on every server after.
Frequently Asked Questions
Eighteen questions builders ask most. These mirror the FAQPage schema at the top of the page, which surfaces them in AI overviews and rich results.
What is the Model Context Protocol (MCP)?
MCP is an open standard introduced by Anthropic in November 2024 that standardizes how AI applications connect to external tools, data sources, and services. It is often described as a USB-C port for AI: any compliant host such as Claude, Cursor, or VS Code Copilot can plug into any compliant server and immediately discover and use its capabilities, with no custom per-integration code.
What are the three MCP primitives?
Tools are executable actions with side effects, such as running a query or calling an API. Resources are read-only URI-addressable data the model can pull at any time. Prompts are reusable parameterized templates the host can surface as slash commands. The rule of thumb: if it has side effects it is a Tool; if it is read-only it is a Resource.
Which transport should my MCP server use?
Use stdio for local servers that run as a child process of the client, which is the common case for personal tooling. Use Streamable HTTP for remote servers; it is the modern recommended transport. The older HTTP plus SSE transport is retained for backwards compatibility only and is deprecated in current clients.
What language should I build my MCP server in?
Both Node.js with TypeScript and Python with FastMCP are first-class. Node.js suits teams already shipping JavaScript tooling and integrates naturally with npm-distributed servers. Python with FastMCP reduces boilerplate to a few decorators and uses type hints for validation. This class builds in Node.js first, then ports the same server to Python.
What is JSON-RPC 2.0 and why does MCP use it?
JSON-RPC 2.0 is a lightweight remote procedure call protocol using JSON messages. MCP uses it as the wire format for all client-server communication because it provides a standardized structure for requests, responses, and notifications, ensuring reliable and interoperable messaging across different platforms and languages.
How do I register an MCP server in Claude Code?
Use the claude mcp add command. For a local stdio server: claude mcp add --transport stdio my-server -- node server.js. All flags such as --transport, --env, and --scope must come before the server name, and a double dash separates the name from the command. Scopes are local (default, private to you in one project), project (shared via a committed .mcp.json file), and user (available across all your projects).
How do I register an MCP server in Antigravity?
Antigravity reads MCP registrations from an mcp_config.json file. Each server is a named entry under the mcpServers key with a command and an args array. After editing the file you restart Antigravity to load the new server. The format mirrors the Claude Code project-scope .mcp.json structure.
How should I handle API keys and secrets in an MCP server?
Never inline secrets in the args array of your server config, because that persists the token to disk in clear text. Instead reference an environment variable using the dollar-brace syntax, and set the actual value in your shell profile or the client's env block. Both Claude Code and Antigravity support environment variable expansion in their MCP config files. Rotate any token that has ever been written to a config file in plain text.
What is the difference between a stateless and a stateful MCP server?
A stateless server tracks no session between requests and scales on ordinary HTTP infrastructure, which is ideal for simple API-style servers. A stateful server assigns session IDs and can support resumability and advanced features that depend on per-session context. Stdio servers are inherently single-session. The choice mainly matters for remote Streamable HTTP servers.
What is the current MCP TypeScript SDK?
The official TypeScript SDK is the modelcontextprotocol SDK package. You create a server with the McpServer class, register tools with registerTool by supplying a name, metadata including a Zod input schema and optional output schema, and an async handler. You then connect a transport such as StdioServerTransport or StreamableHTTPServerTransport.
What is FastMCP?
FastMCP is the high-level Python framework on top of the MCP Python SDK. It uses decorators, namely tool, resource, and prompt, to turn ordinary Python functions into MCP capabilities, with type hints driving input validation so no separate schema library is required. Install it with pip install mcp with the cli extra, or uv add the same; the cli extra includes the development server and inspector integration.
How do I test and debug an MCP server?
Use the MCP Inspector, the official visual testing tool for MCP servers. It connects to your server, lists discovered tools, resources, and prompts, and lets you invoke them with arbitrary inputs while showing the raw JSON-RPC traffic. For stdio servers, also run the server command directly in a terminal and watch stderr, since startup errors surface there before the client ever connects.
How many tools should one MCP server expose?
Keep the tool list short and focused. A handful of well-named tools keeps the agent oriented and reduces the chance of the model picking the wrong one. If a single server grows to twenty or more tools, consider splitting it into multiple focused servers or pruning the long tail. Tool descriptions matter as much as names because the model selects tools from their descriptions.
What is the difference between a Tool and a Resource in practice?
A Tool is model-controlled and may have side effects; the model decides to call it to take an action. A Resource is application-controlled read-only data exposed at a URI that the host can surface to the user or model without an explicit call. Use a Tool to run a database query that filters and computes; use a Resource to expose a static document or a schema the model can reference.
Does my MCP server send credentials to the AI model?
No. The server process holds credentials and performs the privileged operations locally. The model only sees the tool's declared input schema and the structured output your handler returns. Credentials such as database passwords or API tokens never leave the server process, which is a core security property of the architecture.
What changed in the MCP 2026-07-28 specification?
The 2026-07-28 release candidate, published in May 2026, is the largest revision since launch. It introduces a stateless protocol core that scales on ordinary HTTP infrastructure, an Extensions framework, the Tasks extension for long-running work, MCP Apps for server-rendered user interfaces, authorization hardening aligned with OAuth and OpenID Connect, and a formal deprecation policy. The final specification publishes on July 28, 2026.
Can the same MCP server work in both Claude Code and Antigravity?
Yes. MCP is a client-agnostic standard, so a correctly built server works in any compliant host. The server code is identical; only the registration step differs. Claude Code uses the claude mcp add command or a .mcp.json file, while Antigravity uses an mcp_config.json file. The same Node.js or Python server binary is referenced by both.
How big has the MCP ecosystem become?
MCP went from an obscure Anthropic specification in November 2024 to the de facto standard for connecting AI agents to real-world systems. SDK downloads jumped roughly 970 times in eighteen months, and the official server registry crossed 2,000 community implementations by the first quarter of 2026. Major hosts including Claude, Cursor, and VS Code Copilot all support it.
