MCP — the Model Context Protocol — is the closest thing we have to a universal plug for AI applications. Think USB-C, but for connecting LLMs to your tools, data, and workflows.
I've been building MCP servers for a few months now, and while the spec is clean, there's a gap between "it works on my machine" and "this belongs in production." Let me walk you through the primitives, show you real code, and then give you the rules that make the difference.
Table of Contents
- 1. The three primitives
- 2. Architecture: how the pieces fit
- 3. Building a server from scratch
- 4. The rules for production MCP servers
- 4.1 Never write to stdout on stdio servers
- 4.2 Validate inputs aggressively
- 4.3 Keep tools focused — one tool, one job
- 4.4 Descriptions are your API
- 4.5 Return structured, markdown-friendly content
- 4.6 Handle the unhappy path
- 4.7 Mind your secrets
- 4.8 Test with the MCP Inspector
- 4.9 Version your server
- 4.10 One server per domain
- 5. What about Python?
- 6. Bottom line
1. The three primitives
Every MCP server exposes one or more of three capabilities:
| Primitive | Who drives it | What it does | Example |
|---|---|---|---|
| Tools | The model | Model decides when to call them based on context | Search flights, send a message |
| Resources | The app | App-controlled data fetched for context | File contents, database schemas |
| Prompts | The user | Pre-built templates the user explicitly invokes | "Summarize this PR", "Draft email" |
One server can expose all three. A GitHub MCP server, for example, might have:
- Tools:
create_issue,merge_pr,add_comment - Resources:
repository://readme,repository://contributors - Prompts:
review_pr,write_release_notes
Let's break each one down with code.
1.1 Tools — model-controlled actions
Tools are the powerhouse. The model decides when to call them, and the server executes. They're defined with JSON Schema inputs and return structured content.
Here's a real tool definition using the TypeScript SDK:
server.tool(
"search_docs",
"Search the internal documentation for a given query",
{
query: z.string().describe("The search query"),
maxResults: z.number().optional().default(5).describe("Max results to return"),
},
async ({ query, maxResults }) => {
const results = await searchIndex(query, maxResults);
return {
content: results.map(r => ({
type: "text",
text: `## ${r.title}\n${r.snippet}\n[Link](${r.url})`,
})),
};
}
);A few things to notice:
- The description matters. The model reads it to decide whether to call this tool. Be specific. "Searches documentation" is worse than "Searches internal product documentation using full-text search and returns matching pages with snippets."
- Parameter descriptions are just as important. The
z.string().describe(...)isn't decoration — it's how the model knows what to pass. - Return
contentas an array. You can mix text, images, and embedded resources. Plain strings work but the array gives you flexibility.
The wrong tool description will make your server useless because the model won't know when to reach for it. Write them like you're explaining the tool to a new teammate.
1.2 Resources — app-controlled data
Resources are for data the application fetches, not the model. Think of them as file-like objects with URIs.
The key difference from tools: the app decides when to read resources, not the model. They're context injection, not action execution.
server.resource(
"config://database",
"database-config",
async (uri) => ({
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
engine: "PostgreSQL 16",
}, null, 2),
}],
})
);Resources can also have templates — dynamic URIs with path parameters:
server.resource(
"docs://{section}",
"doc-section",
async (uri, { section }) => {
const content = await loadDocsSection(section);
return {
contents: [{
uri: uri.href,
mimeType: "text/markdown",
text: content,
}],
};
}
);Good uses for resources: database schemas the model should know about, API documentation, configuration files, or any read-only data that provides context for the conversation.
1.3 Prompts — user-controlled templates
Prompts are pre-built templates the user invokes explicitly. They're not automatic — the user picks them from a list, like choosing "Draft commit message" from a menu.
server.prompt(
"review_code",
"Review a code snippet for bugs, style issues, and improvements",
{
code: z.string().describe("The code to review"),
language: z.string().optional().describe("Programming language"),
},
({ code, language }) => ({
messages: [{
role: "user",
content: {
type: "text",
text: language
? `Please review this ${language} code:\n\n\`\`\`${language}\n${code}\n\`\`\`\n\nLook for bugs, style issues, and suggest improvements.`
: `Please review this code:\n\n\`\`\`\n${code}\n\`\`\`\n\nLook for bugs, style issues, and suggest improvements.`,
},
}],
})
);Prompts shine for repetitive workflows. Instead of typing "Please generate a changelog from these commits" every time, you make it a prompt and invoke it with one click.
2. Architecture: how the pieces fit
The architecture has three layers:
┌─────────────────────────────────┐
│ MCP Host (Claude, ChatGPT, VS Code) │
│ ┌──────────┐ ┌──────────┐ │
│ │ Client 1 │ │ Client 2 │ ... │
│ └────┬─────┘ └────┬─────┘ │
└───────┼──────────────┼───────────┘
│ │
┌────▼─────┐ ┌────▼─────┐
│ Server A │ │ Server B │
│(local) │ │(remote) │
└──────────┘ └──────────┘
- MCP Host: The AI application (Claude Desktop, VS Code, Cursor)
- MCP Client: One per server connection, managed by the host
- MCP Server: Your code, providing tools/resources/prompts
There are two transports:
- stdio — The server runs as a subprocess, communicating over standard input/output. Simple, local, one client per server. Good for dev tools and personal servers.
- Streamable HTTP — The server runs as an HTTP endpoint. Remote, multi-client, stateless. Good for team servers and cloud deployments.
For most personal-use servers, stdio is the right call. For anything shared across a team, go HTTP.
3. Building a server from scratch
Let's build a practical server — a bookmark manager that lets an AI assistant save, search, and retrieve bookmarks. We'll use TypeScript with the official SDK.
3.1 Setup
mkdir bookmark-server && cd bookmark-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D @types/node typescript tsx3.2 The server
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// In-memory store (use a real DB in production)
const bookmarks: { url: string; title: string; tags: string[] }[] = [];
const server = new McpServer({
name: "bookmark-manager",
version: "1.0.0",
});
// Tool: add a bookmark
server.tool(
"add_bookmark",
"Save a URL as a bookmark with optional tags",
{
url: z.string().url().describe("The URL to bookmark"),
title: z.string().describe("A human-readable title for the bookmark"),
tags: z.array(z.string()).optional().default([])
.describe("Optional tags for categorization"),
},
async ({ url, title, tags }) => {
bookmarks.push({ url, title, tags });
return {
content: [{ type: "text", text: `Bookmarked: "${title}"` }],
};
}
);
// Tool: search bookmarks
server.tool(
"search_bookmarks",
"Search saved bookmarks by title or tag",
{
query: z.string().describe("Search term (matches title and tags)"),
},
async ({ query }) => {
const q = query.toLowerCase();
const results = bookmarks.filter(
b => b.title.toLowerCase().includes(q) ||
b.tags.some(t => t.toLowerCase().includes(q))
);
if (results.length === 0) {
return {
content: [{ type: "text", text: "No bookmarks found." }],
};
}
return {
content: results.map(b => ({
type: "text",
text: `- **${b.title}**\n ${b.url}\n Tags: ${b.tags.join(", ") || "none"}`,
})),
};
}
);
// Resource: export all bookmarks
server.resource(
"bookmarks://all",
"all-bookmarks",
async (uri) => ({
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(bookmarks, null, 2),
}],
})
);
// Prompt: organize bookmarks
server.prompt(
"organize_bookmarks",
"Review all bookmarks and suggest tag improvements and categorization",
{},
() => {
const bookmarkList = bookmarks
.map(b => `- ${b.title} (${b.url}) [tags: ${b.tags.join(", ") || "none"}]`)
.join("\n");
return {
messages: [{
role: "user",
content: {
type: "text",
text: `Here are my current bookmarks:\n\n${bookmarkList}\n\nPlease suggest:\n1. Missing tags that would improve organization\n2. Bookmarks that should be grouped together\n3. Any bookmarks that might be outdated or duplicates`,
},
}],
};
}
);
// Start the server
const transport = new StdioServerTransport();
await server.connect(transport);This is a working server. Run it, register it with Claude Desktop, and you can ask "what did I bookmark about MCP?" or "organize my bookmarks."
But this is a proof of concept. Let's talk about what it takes to make it production-grade.
4. The rules for production MCP servers
4.1 Never write to stdout on stdio servers
This is the most common bug and the hardest to debug. The stdio transport uses stdout for JSON-RPC messages. A stray console.log corrupts the protocol and the server silently breaks.
// ❌ This will break your server
console.log("Processing bookmark...");
// ✅ Use stderr for all logging
console.error("Processing bookmark...");
// ✅ Better: use a proper logger pointing to stderr
import pino from "pino";
const logger = pino({}, process.stderr);
logger.info("Processing bookmark...");For HTTP servers, stdout logging is fine since it doesn't interfere with HTTP responses.
4.2 Validate inputs aggressively
The model will pass you weird values. It'll send an empty string where you expected a URL. It'll send a number where you expected a string. Zod handles basic validation, but add semantic validation too:
server.tool(
"add_bookmark",
"Save a URL as a bookmark",
{ url: z.string().url(), title: z.string().min(1).max(500) },
async ({ url, title }) => {
// Extra validation Zod can't express
if (!url.startsWith("http")) {
return {
content: [{ type: "text", text: "Only HTTP(S) URLs are supported." }],
isError: true,
};
}
// ... rest of tool
}
);Return errors with isError: true instead of throwing. The client handles these gracefully and the model can correct itself.
4.3 Keep tools focused — one tool, one job
A tool called manage_project that creates, updates, deletes, and lists projects is bad. The model has to guess which parameters to pass, and the description becomes a novel.
// ❌ Too broad
server.tool("manage_project", "...", { action: z.enum(["create", "update", "delete", "list"]), ... }, ...)
// ✅ One job per tool
server.tool("create_project", "...", { name: z.string(), ... }, ...)
server.tool("list_projects", "...", {}, ...)
server.tool("delete_project", "...", { id: z.string() }, ...)The model is better at choosing the right tool than at figuring out which parameters to send to a Swiss Army knife.
4.4 Descriptions are your API
The model can't read your source code. It can't read your docs. It has exactly three things to go on: the tool name, the tool description, and the parameter descriptions. Every word counts.
A bad description: "Add a bookmark"
A good description: "Save a URL as a bookmark with an optional title and tags. Use this when the user wants to remember a link for later."
The good one tells the model when to use the tool, not just what it does.
4.5 Return structured, markdown-friendly content
The model understands markdown. Use it:
// ❌ Raw text blob
{ type: "text", text: `Found ${results.length} items. ${results.map(r => r.name).join(", ")}` }
// ✅ Structured markdown
{ type: "text", text: `Found ${results.length} results:\n\n${results.map(r =>
`- **${r.name}** — ${r.description}\n \`${r.id}\``
).join("\n\n")}` }Tables, lists, and bold text help the model parse your output and present it cleanly to the user. Don't make the model work harder than it has to.
4.6 Handle the unhappy path
The model will call your tools in unexpected ways. A search might return zero results. An API you depend on might be down. A file might not exist.
async ({ query }) => {
try {
const results = await externalApi.search(query);
if (results.length === 0) {
return {
content: [{ type: "text", text: `No results found for "${query}". Try different keywords.` }],
};
}
return { content: formatResults(results) };
} catch (error) {
logger.error("Search failed", { query, error });
return {
content: [{ type: "text", text: "Search is temporarily unavailable. Please try again in a moment." }],
isError: true,
};
}
}Never let an unhandled exception bubble up. The model gets a cryptic error, the user gets confused, and you get a support headache.
4.7 Mind your secrets
MCP servers often need API keys, database credentials, or tokens. Never hardcode them. Never accept them as tool parameters (the conversation history might be logged).
Use environment variables. On stdio servers, the parent process sets them. On HTTP servers, use your platform's secret manager.
4.8 Test with the MCP Inspector
The MCP Inspector is a browser-based tool that lets you list tools, call them manually, and inspect responses without needing an AI host.
npx @modelcontextprotocol/inspector node dist/server.jsTest every tool with valid inputs, invalid inputs, and edge cases before connecting it to a real model. The Inspector catches protocol-level issues that console.error won't show you.
4.9 Version your server
The version field in your server config isn't decorative. When you change tool signatures or add breaking changes, bump the version. Clients can detect version mismatches and handle them gracefully.
const server = new McpServer({
name: "bookmark-manager",
version: "1.2.0", // ← bump this when you change tool signatures
});4.10 One server per domain
Don't build a monolithic server that manages bookmarks, sends emails, queries databases, and controls your smart home. Split concerns:
bookmark-server— bookmark managementemail-server— email composition and searchdatabase-server— database introspection and queries
The host connects to multiple servers. Each one should do one thing well. This also makes permissions clearer: you can grant or deny access per server.
5. What about Python?
The concepts are identical. The Python SDK is equally mature:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("bookmark-manager")
@mcp.tool()
async def add_bookmark(url: str, title: str, tags: list[str] | None = None) -> str:
"""Save a URL as a bookmark with an optional title and tags."""
# ... implementation
return f'Bookmarked: "{title}"'FastMCP uses type hints and docstrings for tool definitions — no Zod equivalent needed. The same rules apply: validate inputs, return structured output, log to stderr.
6. Bottom line
MCP servers are less about the protocol and more about good API design. The protocol handles the plumbing. What makes your server useful is clear descriptions, focused tools, and graceful error handling.
If you take one thing away: write descriptions like you're onboarding a new developer. The model is that developer — it's smart, it can figure things out, but it needs you to tell it what your tools do and when to use them.
The code for the bookmark server is at github.com/atascg01/mcp-bookmark-server — clone it, extend it, break it.
