How to Build Your Own MCP Server [Step-by-Step Guide]
An MCP server is a lightweight program that exposes tools, resources, and prompts to AI models through the Model Context Protocol -- Anthropic's open standard for connecting AI applications to external capabilities. Building one is simpler than most developers expect. A functional MCP server can be written in under fifty lines of code.
This tutorial walks you through building an MCP server from scratch in both TypeScript and Python. You will go from an empty directory to a working server that an AI agent can use -- with real tools, typed inputs, resource endpoints, and transport configuration. No prior MCP experience required.
If you are unfamiliar with the protocol itself, start with our guide on what MCP is and how it works before diving into implementation. For background on how MCP compares to REST APIs and function calling, read MCP vs API vs function calling. For foundational context on how agents use tools, see what AI agents are.
What You Will Build
By the end of this tutorial, you will have a working MCP server that exposes:
- Two tools -- executable functions an AI model can call to perform actions
- One resource -- a data endpoint the model can read for context
- One prompt template -- a reusable instruction template the model can invoke
The server will connect via stdio transport for local use with Claude Code, Claude Desktop, Cursor, or any other MCP-compatible client.
Prerequisites
You need one of the following, depending on your language choice:
TypeScript path:
- Node.js 18+ (LTS recommended)
- npm or a package manager of your choice
- Basic TypeScript familiarity
Python path:
- Python 3.10+
-
uvpackage manager (recommended) orpip - Basic Python familiarity
Both paths produce equivalent results. Pick whichever language you are more comfortable writing in.
Step 1: Project Setup
TypeScript Setup
Create a new project and install the official MCP SDK:
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
npx tsc --init
The two key dependencies:
-
@modelcontextprotocol/sdk-- the official TypeScript SDK for MCP servers and clients -
zod-- schema validation library used by the SDK to define tool parameters
Update your tsconfig.json to target a modern runtime:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
}
}
Create your server file:
mkdir src && touch src/index.ts
Python Setup
Create a project with uv and install the MCP SDK:
uv init my-mcp-server && cd my-mcp-server
uv venv && source .venv/bin/activate
uv add "mcp[cli]"
touch server.py
The mcp package includes FastMCP, which uses Python type hints and docstrings to automatically generate tool schemas. This is the fastest path from zero to a working server.
Step 2: Initialize the Server
The server object is the core of every MCP implementation. It handles capability negotiation, message routing, and transport management.
TypeScript
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "my-mcp-server",
version: "1.0.0",
});
// Tools, resources, and prompts will be registered here.
// (See Steps 3-5 below.)
// Connect via stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);
The McpServer class provides a high-level API that handles JSON-RPC message framing, capability advertisements, and session lifecycle. You register your tools, resources, and prompts on this object.
Python
# server.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("my-mcp-server")
# Tools, resources, and prompts will be defined here.
# (See Steps 3-5 below.)
if __name__ == "__main__":
mcp.run()
FastMCP is a high-level wrapper that infers tool schemas from your Python type annotations and docstrings. One line of initialization, and you are ready to start registering capabilities.
Step 3: Define Your Tools
Tools are executable functions that the AI model can call. They are the most commonly used MCP primitive. When a model decides it needs to take an action -- query an API, calculate a value, modify a file -- it invokes a tool.
Every tool needs three things: a name, a description the model reads to decide when to use it, and input validation.
TypeScript -- Register Two Tools
// Tool 1: A text analysis tool
server.registerTool(
"analyze-text",
{
title: "Text Analyzer",
description: "Analyze text for word count, character count, and reading level.",
inputSchema: z.object({
text: z.string().describe("The text to analyze"),
}),
},
async ({ text }) => {
const words = text.split(/\s+/).filter(Boolean).length;
const chars = text.length;
const sentences = text.split(/[.!?]+/).filter(Boolean).length;
const avgWordsPerSentence = sentences > 0 ? words / sentences : 0;
// Simple readability estimate
let level = "elementary";
if (avgWordsPerSentence > 20) level = "advanced";
else if (avgWordsPerSentence > 14) level = "intermediate";
return {
content: [
{
type: "text",
text: JSON.stringify(
{ words, characters: chars, sentences, readingLevel: level },
null,
2
),
},
],
};
}
);
// Tool 2: A timestamp utility
server.registerTool(
"current-time",
{
title: "Current Time",
description:
"Get the current date and time in a specified timezone. Defaults to UTC.",
inputSchema: z.object({
timezone: z
.string()
.optional()
.describe("IANA timezone (e.g., 'America/New_York')"),
}),
},
async ({ timezone }) => {
const tz = timezone || "UTC";
const now = new Date().toLocaleString("en-US", { timeZone: tz });
return {
content: [{ type: "text", text: `Current time in ${tz}: ${now}` }],
};
}
);
Notice how zod schemas define the input types. The SDK converts these schemas into the JSON Schema format that MCP clients expect, and validates incoming arguments automatically. Bad inputs get rejected before your handler runs.
Python -- Register Two Tools
@mcp.tool()
def analyze_text(text: str) -> str:
"""Analyze text for word count, character count, and reading level.
Args:
text: The text to analyze
"""
words = len(text.split())
chars = len(text)
sentences = len([s for s in text.split(".") if s.strip()])
avg_words = words / sentences if sentences > 0 else 0
level = "elementary"
if avg_words > 20:
level = "advanced"
elif avg_words > 14:
level = "intermediate"
return (
f"Words: {words}\n"
f"Characters: {chars}\n"
f"Sentences: {sentences}\n"
f"Reading level: {level}"
)
@mcp.tool()
def current_time(timezone: str = "UTC") -> str:
"""Get the current date and time in a specified timezone.
Args:
timezone: IANA timezone string (e.g., 'America/New_York'). Defaults to UTC.
"""
from datetime import datetime
from zoneinfo import ZoneInfo
tz = ZoneInfo(timezone)
now = datetime.now(tz)
return f"Current time in {timezone}: {now.strftime('%Y-%m-%d %H:%M:%S %Z')}"
In Python, FastMCP reads your function signature and docstring to generate the tool's schema. The type hints become the input schema. The docstring becomes the tool description. The Args section in the docstring describes each parameter. No separate schema definition required.
Step 4: Define a Resource
Resources are data endpoints that the model can read. Unlike tools, resources do not perform actions -- they provide information. Think configuration files, database records, status dashboards, or any structured data the model might need for context.
TypeScript
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
// Static resource at a fixed URI
server.registerResource(
"server-status",
"status://server",
{
title: "Server Status",
description: "Current server status and uptime information",
mimeType: "application/json",
},
async (uri) => ({
contents: [
{
uri: uri.href,
text: JSON.stringify(
{
status: "running",
uptime: process.uptime(),
version: "1.0.0",
tools: ["analyze-text", "current-time"],
},
null,
2
),
},
],
})
);
You can also create dynamic resources with URI templates. These let the model request different data based on parameters embedded in the URI:
server.registerResource(
"document",
new ResourceTemplate("docs://{docId}", {
list: async () => ({
resources: [
{ uri: "docs://readme", name: "README" },
{ uri: "docs://changelog", name: "Changelog" },
],
}),
}),
{ title: "Project Document", description: "Fetch a project document by ID" },
async (uri, { docId }) => ({
contents: [
{
uri: uri.href,
text: `Contents of document: ${docId}`,
},
],
})
);
Python
@mcp.resource("status://server")
def server_status() -> str:
"""Current server status and uptime information."""
import json, time
return json.dumps(
{
"status": "running",
"version": "1.0.0",
"tools": ["analyze_text", "current_time"],
},
indent=2,
)
For dynamic resources with parameters, use URI templates:
@mcp.resource("docs://{doc_id}")
def get_document(doc_id: str) -> str:
"""Fetch a project document by its identifier."""
documents = {
"readme": "# My Project\nA sample project for MCP.",
"changelog": "## v1.0.0\n- Initial release",
}
return documents.get(doc_id, f"Document '{doc_id}' not found.")
Step 5: Define a Prompt Template
Prompts are reusable instruction templates that help users accomplish specific tasks. They are the least used MCP primitive, but valuable for encoding complex workflows into a single invocation.
TypeScript
server.registerPrompt(
"summarize",
{
title: "Summarize Text",
description: "Generate a concise summary of the provided text",
argsSchema: z.object({
text: z.string().describe("The text to summarize"),
style: z
.enum(["brief", "detailed", "bullet-points"])
.optional()
.describe("Summary style"),
}),
},
({ text, style }) => ({
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: `Summarize the following text${style ? ` in ${style} style` : ""}:\n\n${text}`,
},
},
],
})
);
Python
@mcp.prompt()
def summarize(text: str, style: str = "brief") -> str:
"""Generate a concise summary of the provided text.
Args:
text: The text to summarize
style: Summary style -- 'brief', 'detailed', or 'bullet-points'
"""
return f"Summarize the following text in {style} style:\n\n{text}"
Step 6: Transport Configuration
Transport determines how the client communicates with your server. MCP supports two primary transport mechanisms:
Stdio Transport (Local Servers)
Stdio is the default for local MCP servers. The client spawns your server as a child process and communicates via standard input/output. This is how Claude Code, Claude Desktop, Cursor, and most desktop clients connect to local servers.
TypeScript -- already configured in Step 2:
const transport = new StdioServerTransport();
await server.connect(transport);
Python -- already configured in Step 2. mcp.run() defaults to stdio transport.
Streamable HTTP Transport (Remote Servers)
For servers that need to be accessed over the network -- shared team tools, cloud-deployed services, production APIs -- use HTTP transport:
import express from "express";
import {
NodeStreamableHTTPServerTransport
} from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless mode
});
const server = new McpServer({ name: "my-mcp-server", version: "1.0.0" });
// Register tools, resources, prompts...
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3000, "127.0.0.1", () => {
console.error("MCP server listening on http://127.0.0.1:3000/mcp");
});
For most use cases, start with stdio. Move to HTTP only when you need remote access or multi-user support.
Step 7: The Complete Server
Here is the full TypeScript implementation in one file, ready to run:
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "my-mcp-server",
version: "1.0.0",
});
// --- Tools ---
server.registerTool(
"analyze-text",
{
title: "Text Analyzer",
description:
"Analyze text for word count, character count, and reading level.",
inputSchema: z.object({
text: z.string().describe("The text to analyze"),
}),
},
async ({ text }) => {
const words = text.split(/\s+/).filter(Boolean).length;
const chars = text.length;
const sentences = text.split(/[.!?]+/).filter(Boolean).length;
const avgWordsPerSentence = sentences > 0 ? words / sentences : 0;
let level = "elementary";
if (avgWordsPerSentence > 20) level = "advanced";
else if (avgWordsPerSentence > 14) level = "intermediate";
return {
content: [
{
type: "text",
text: JSON.stringify(
{ words, characters: chars, sentences, readingLevel: level },
null,
2
),
},
],
};
}
);
server.registerTool(
"current-time",
{
title: "Current Time",
description:
"Get the current date and time in a specified timezone. Defaults to UTC.",
inputSchema: z.object({
timezone: z
.string()
.optional()
.describe("IANA timezone (e.g., 'America/New_York')"),
}),
},
async ({ timezone }) => {
const tz = timezone || "UTC";
const now = new Date().toLocaleString("en-US", { timeZone: tz });
return {
content: [{ type: "text", text: `Current time in ${tz}: ${now}` }],
};
}
);
// --- Resources ---
server.registerResource(
"server-status",
"status://server",
{
title: "Server Status",
description: "Current server status and uptime information",
mimeType: "application/json",
},
async (uri) => ({
contents: [
{
uri: uri.href,
text: JSON.stringify(
{
status: "running",
uptime: process.uptime(),
version: "1.0.0",
},
null,
2
),
},
],
})
);
// --- Prompts ---
server.registerPrompt(
"summarize",
{
title: "Summarize Text",
description: "Generate a concise summary of the provided text",
argsSchema: z.object({
text: z.string().describe("The text to summarize"),
style: z
.enum(["brief", "detailed", "bullet-points"])
.optional()
.describe("Summary style"),
}),
},
({ text, style }) => ({
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: `Summarize the following text${style ? ` in ${style} style` : ""}:\n\n${text}`,
},
},
],
})
);
// --- Start ---
const transport = new StdioServerTransport();
await server.connect(transport);
Build and test:
npx tsc
node dist/index.js
And here is the complete Python version:
# server.py
import json
from datetime import datetime
from zoneinfo import ZoneInfo
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("my-mcp-server")
@mcp.tool()
def analyze_text(text: str) -> str:
"""Analyze text for word count, character count, and reading level.
Args:
text: The text to analyze
"""
words = len(text.split())
chars = len(text)
sentences = len([s for s in text.split(".") if s.strip()])
avg_words = words / sentences if sentences > 0 else 0
level = "elementary"
if avg_words > 20:
level = "advanced"
elif avg_words > 14:
level = "intermediate"
return json.dumps(
{
"words": words,
"characters": chars,
"sentences": sentences,
"reading_level": level,
},
indent=2,
)
@mcp.tool()
def current_time(timezone: str = "UTC") -> str:
"""Get the current date and time in a specified timezone.
Args:
timezone: IANA timezone string (e.g., 'America/New_York')
"""
tz = ZoneInfo(timezone)
now = datetime.now(tz)
return f"Current time in {timezone}: {now.strftime('%Y-%m-%d %H:%M:%S %Z')}"
@mcp.resource("status://server")
def server_status() -> str:
"""Current server status and uptime information."""
return json.dumps(
{"status": "running", "version": "1.0.0"}, indent=2
)
@mcp.prompt()
def summarize(text: str, style: str = "brief") -> str:
"""Generate a concise summary of the provided text.
Args:
text: The text to summarize
style: Summary style -- 'brief', 'detailed', or 'bullet-points'
"""
return f"Summarize the following text in {style} style:\n\n{text}"
if __name__ == "__main__":
mcp.run()
Run the Python server:
python server.py
Step 8: Testing Your Server
Testing with Claude Code
Claude Code is the fastest way to test a local MCP server. Add your server with a single command:
TypeScript:
claude mcp add my-mcp-server node /path/to/dist/index.js
Python:
claude mcp add my-mcp-server python /path/to/server.py
Once added, start a Claude Code session and ask it to use your tools:
> Analyze this text for reading level: "The quick brown fox jumps over the lazy dog."
> What time is it in America/New_York?
Claude will discover your registered tools automatically and call them when relevant.
Testing with Claude Desktop
For Claude Desktop, add the server to your configuration file:
macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
Windows: %APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"my-mcp-server": {
"command": "node",
"args": ["/absolute/path/to/dist/index.js"]
}
}
}
Restart Claude Desktop, and your tools will appear in the tool menu.
Testing with MCP Inspector
The MCP Inspector is a visual debugging tool built by the MCP team:
npx @modelcontextprotocol/inspector node dist/index.js
This opens a browser interface where you can:
- See all registered tools, resources, and prompts
- Invoke tools manually with test inputs
- Inspect the JSON-RPC messages exchanged between client and server
- Debug issues without needing a full AI client
Step 9: Publishing and Sharing
Once your server works locally, you may want to share it.
npm (TypeScript)
Add a bin field to your package.json and publish:
{
"name": "my-mcp-server",
"version": "1.0.0",
"bin": {
"my-mcp-server": "./dist/index.js"
}
}
Add a shebang to the top of src/index.ts:
#!/usr/bin/env node
npm publish
Users install and connect:
npm install -g my-mcp-server
claude mcp add my-mcp-server my-mcp-server
PyPI (Python)
Use uv or setuptools to package and publish:
uv build
uv publish
Users install and connect:
pip install my-mcp-server
claude mcp add my-mcp-server python -m my_mcp_server
GitHub
The simplest distribution method. Users clone the repo and add it directly:
git clone https://github.com/your-username/my-mcp-server.git
claude mcp add my-mcp-server node /path/to/my-mcp-server/dist/index.js
For a curated list of production MCP servers you can use as reference, see our guide to the best MCP servers in 2026.
Best Practices
Write Descriptive Tool Descriptions
The tool description is how the AI model decides whether to call your tool. A vague description like "does stuff with text" means the model will rarely use it correctly. Be specific about what the tool does, what inputs it expects, and what it returns.
Validate Inputs Aggressively
Use Zod schemas (TypeScript) or type hints (Python) to validate every input. The SDK handles validation automatically, but you should define schemas that are as specific as possible. Use .describe() on every field to give the model context about what it should provide.
Keep Tools Focused
Each tool should do one thing well. A tool that "analyzes text, translates it, and formats the output" is three tools pretending to be one. Split it. The model can chain multiple focused tools more effectively than it can navigate a monolithic one.
Handle Errors Gracefully
Return meaningful error messages in your tool responses. The model reads these to decide what to do next. A generic "Error occurred" tells it nothing. "API rate limit exceeded, retry after 60 seconds" tells it exactly how to recover.
Use Resources for Context, Tools for Actions
If the model needs to read data before deciding what to do, expose it as a resource. If the model needs to perform an action, expose it as a tool. Do not force the model to call a tool just to read information.
FAQ
How long does it take to build an MCP server?
A basic MCP server with two or three tools can be built in under an hour. The official SDKs handle all the protocol complexity -- JSON-RPC framing, capability negotiation, transport management -- so your implementation focuses entirely on the business logic of your tools. The examples in this tutorial are production-ready starting points.
What is the difference between stdio and HTTP transport?
Stdio transport runs your server as a local child process. The client spawns it, communicates via stdin/stdout, and kills it when done. This is ideal for personal tools and local development. HTTP transport runs your server as a network service that multiple clients can connect to simultaneously. Use HTTP when you need remote access, team-shared tools, or cloud deployment.
Can I use MCP servers with AI clients other than Claude?
Yes. MCP is an open standard adopted by OpenAI (ChatGPT), Google (Gemini), Microsoft (Copilot), Cursor, Windsurf, JetBrains IDEs, and dozens of other AI applications. An MCP server built once works with any compliant client. This is the core value proposition of the protocol -- build once, connect everywhere.
Do I need to handle authentication in my MCP server?
For local stdio servers, authentication is usually unnecessary because the server runs in the user's own process space with their permissions. For HTTP servers exposed over a network, you should implement authentication. The MCP specification supports OAuth 2.1 for remote servers, and the official SDKs include auth helpers for this purpose.
What is the best way to debug an MCP server?
Use the MCP Inspector (npx @modelcontextprotocol/inspector) for visual debugging -- it shows you every JSON-RPC message exchanged between client and server. For logging within your server, write to stderr (not stdout) when using stdio transport, since stdout is reserved for protocol messages. The SDK also provides a built-in logging capability you can enable for structured log output.
What Comes Next
You now have a working MCP server. The natural next steps:
- Add real functionality. Replace the example tools with capabilities that solve actual problems in your workflow -- database queries, API integrations, file operations, custom calculations.
- Explore existing servers. Before building from scratch, check whether someone has already built what you need. The best MCP servers in 2026 is a good starting point.
- Understand the protocol. If you want to go deeper into how MCP works under the hood -- architecture, transport layers, security model -- read our comprehensive MCP explainer.
- Combine with skills. MCP servers give AI agents tools. Skills give them procedural knowledge about how to use those tools well. The most capable agent systems use both.
The protocol is open, the SDKs are mature, and the ecosystem is growing fast. The best time to build was six months ago. The second best time is now.