Building a Model Context Protocol server is straightforward β the official SDKs handle the protocol; you handle the tool logic. This is the practical guide for shipping a real server in a day.
What you're building
An MCP server exposes tools, resources, and prompts that any MCP-compliant agent (Claude Code, Cursor, Cline, Continue, Windsurf) can discover and use.
We'll build a minimal example: an MCP server that exposes one tool ("look up customer in our database"). Once you understand the shape, scaling to 10+ tools is the same pattern.
Decide: stdio or HTTP?
Two transport options:
- stdio β runs as a local process on the user's machine. Started by the MCP client. Easiest distribution (
npx -y @your-org/mcp-serverorpip install). Best for public ecosystem servers + dev tools. - HTTP β runs as a remote service. Authenticated per-user. Best for enterprise + multi-user scenarios where the server holds shared state or credentials the user shouldn't have locally.
For this guide we'll do stdio (simpler). The HTTP variant uses the same SDK with different bootstrap.
TypeScript minimal example
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
Create index.ts:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
const server = new Server(
{ name: "customer-lookup", version: "0.1.0" },
{ capabilities: { tools: {} } }
);
const LookupInputSchema = z.object({
customerId: z.string().describe("Customer ID, e.g. 'cus_abc123'"),
});
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{
name: "lookup_customer",
description: "Look up a customer record by ID. Returns name, email, plan, MRR.",
inputSchema: {
type: "object",
properties: {
customerId: { type: "string", description: "Customer ID, e.g. 'cus_abc123'" },
},
required: ["customerId"],
},
}],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name !== "lookup_customer") {
throw new Error(`Unknown tool: ${request.params.name}`);
}
const { customerId } = LookupInputSchema.parse(request.params.arguments);
// YOUR REAL LOGIC HERE β call your DB, your API, whatever.
const customer = await fetchCustomerFromYourDB(customerId);
return {
content: [{
type: "text",
text: JSON.stringify(customer, null, 2),
}],
};
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch(console.error);
async function fetchCustomerFromYourDB(id: string) {
// Replace with your actual logic
return { id, name: "Example Customer", email: "x@y.com", plan: "pro", mrr: 99 };
}
Run it:
npx tsx index.ts
That's the entire server. Test it by configuring Claude Code or Cursor to use it.
Python minimal example
mkdir my-mcp-server && cd my-mcp-server
pip install mcp
Create server.py:
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
server = Server("customer-lookup")
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="lookup_customer",
description="Look up a customer record by ID. Returns name, email, plan, MRR.",
inputSchema={
"type": "object",
"properties": {
"customerId": {"type": "string", "description": "Customer ID"}
},
"required": ["customerId"],
},
)
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if name != "lookup_customer":
raise ValueError(f"Unknown tool: {name}")
customer = await fetch_customer_from_db(arguments["customerId"])
return [TextContent(type="text", text=str(customer))]
async def fetch_customer_from_db(id: str) -> dict:
return {"id": id, "name": "Example", "email": "x@y.com", "plan": "pro"}
async def main():
async with stdio_server() as (read, write):
await server.run(read, write, server.create_initialization_options())
if __name__ == "__main__":
import asyncio
asyncio.run(main())
Same shape, different language.
Configure a client to use your server
In Claude Code's ~/.claude/config.json:
{
"mcpServers": {
"customer-lookup": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/index.ts"]
}
}
}
Restart Claude Code. Type "look up customer cus_abc123" β Claude will use your tool.
Cursor: similar config in Settings β MCP Servers.
Production-grade patterns
The 30-minute server above is fine for prototyping. For production:
1. Handle errors well
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
// ... your tool logic
} catch (err) {
return {
content: [{ type: "text", text: `Error: ${err.message}` }],
isError: true,
};
}
});
The agent sees the error and can respond intelligently ("the customer ID looks malformed, can you check?").
2. Validate inputs
Use Zod (TS) or Pydantic (Python). Never trust the agent's inputs without validation β the LLM will hallucinate parameter shapes.
3. Add logging
The MCP server runs invisibly. Without logs, you can't debug. Log every tool call + the args + the result.
console.error(`[lookup_customer] ${customerId} β ${customer ? "found" : "not found"}`);
(Use console.error not console.log β stdio MCP servers reserve stdout for the protocol.)
4. Rate-limit + auth where needed
For HTTP servers facing the open internet, rate-limit per-API-key. Auth model is up to you β most servers use bearer tokens.
5. Provide good tool descriptions
The agent picks which tool to use based on the description field. Be specific. "Look up customer by ID β returns name, email, plan, MRR" is much better than "Customer lookup tool."
Publishing to the ecosystem
If your server is broadly useful:
- Publish to npm (TS) or PyPI (Python)
- Submit to the MCP servers registry
- Write README with: install command, config snippet, list of tools + their inputs/outputs
- Tag releases properly so users can pin versions
The MCP ecosystem has a strong community-curation norm β well-documented servers get adopted.
Common pitfalls
- Logging to stdout. Breaks the protocol. Use stderr.
- Blocking the event loop. Tool calls should be async. Slow sync calls freeze the server.
- Inconsistent error shapes. Always return
{ isError: true }for tool errors β don't throw + crash. - Tool descriptions that confuse the LLM. Write descriptions like API documentation.
- Over-scoped tools. Don't write one mega-tool that does 10 things. Write 10 narrow tools β agents pick the right one.
What to build next
The fastest way to get good at MCP is to look at existing servers. Browse the MCP servers registry β the official ones (filesystem, github, fetch, brave-search, postgres) are well-engineered examples.
Then ship your own. The ecosystem rewards specific + well-described servers.
See also
Bottom line
Building a real MCP server takes 30 minutes to start, a day to polish, a half-day to publish. The protocol is small + well-documented; the SDKs do the heavy lifting. If you maintain APIs at your company, shipping an MCP server for them is the highest-leverage one-day project you can do.