MCP, the Model Context Protocol, connects AI applications to tools, data, and workflows through a common interface.
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." The examples below cover the protocol's primitives and the work needed to prepare a server for production.
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
Each capability has a different role.
1.1 Tools — model-controlled actions
The model decides when to call tools, and the server executes them. They're defined with JSON Schema inputs and return structured content.
Here's a 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(...)tells the model what to pass. - Return
contentas an array. You can mix text, images, and embedded resources. Plain strings work but the array gives you flexibility.
An unclear tool description can leave the model unsure when to use 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. The user picks them explicitly 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
The example server is a bookmark manager that lets an AI assistant save, search, and retrieve bookmarks. It uses 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."
This is a proof of concept. A production server needs the validation, error handling, and testing described below.
4. The rules for production MCP servers
4.1 Never write to stdout on stdio servers
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 makes the model guess which parameters to pass and requires a long description.
// ❌ 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() }, ...)Separate tools let the model choose an operation without also selecting among several parameter sets.
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.
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,
};
}
}Handle exceptions so the model and user receive a useful error message.
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 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, without needing a Zod equivalent. The same rules apply: validate inputs, return structured output, log to stderr.
6. Bottom line
Clear descriptions, focused tools, and useful error responses make an MCP server easier for a model to use. The protocol provides the connection; API design determines how well the tools work through it.
Write descriptions as you would for a new developer: explain what each tool does and when to use it.
The code for the bookmark server is at github.com/atascg01/mcp-bookmark-server. You can clone it and try extending it.
Test Your Understanding
Question 1 of 5 · Score: 0/0 correct
Your first answer counts toward your score.
