How to Build an MCP Server
An MCP server is a small program that tells an AI app which tools it offers and runs them when the model calls them. With the official SDK you write each tool as an ordinary function with a description and typed parameters, then start the server over stdio so a desktop app like Claude Desktop can launch it. The whole server below is about 70 lines in Python or TypeScript, and takes around 15 minutes to build and connect.
This guide uses Python and TypeScript. The same server is also built in Go, C#, Java and Rust, each with the official SDK for that language.
What you'll have at the end
A bookmarks server with two tools, save_bookmark and search_bookmarks, that any MCP-compatible AI app can call. You'll be able to say "bookmark modelcontextprotocol.io as MCP docs" and later "find my MCP bookmarks", and the model will call your code to do it. This is the result of calling the tool from MCP Inspector:
{
"content": [
{
"type": "text",
"text": "Saved 'MCP docs'. Total bookmarks: 1"
}
],
"isError": false
}
The AI app starts your server as a child process and exchanges JSON-RPC messages with it over standard input and output. Your server never talks to the model directly; the app passes tool calls through.
Before you start
- Python: Python 3.10 or later and uv. The server needs version 2.0.0 or later of the
mcppackage. - TypeScript: Node.js 20 or later and npm.
- For testing, either language: Node.js 22.19 or later, which MCP Inspector needs.
- Basic comfort with a terminal.
- To connect it at the end: Claude Desktop, or any other AI app from MCP clients.
1. Set up the project
- Python
- TypeScript
uv init bookmarks
cd bookmarks
uv add "mcp[cli]"
uv add installs the official MCP Python SDK and records it in pyproject.toml. You can delete the main.py that uv init creates; the server goes in bookmarks.py.
mkdir bookmarks
cd bookmarks
npm init -y
npm install @modelcontextprotocol/server zod
npm install -D @types/node typescript
mkdir src
The TypeScript SDK is split into packages. @modelcontextprotocol/server is the one for servers, and Zod describes each tool's parameters. Set package.json to use ES modules and add a build script:
{
"type": "module",
"bin": {
"bookmarks": "./build/index.js"
},
"scripts": {
"build": "tsc && chmod 755 build/index.js"
},
"files": ["build"]
}
Then create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"types": ["node"],
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
2. Write the server
Each tool needs three things: a name, a description the model reads to decide when to call it, and a schema for its parameters. The SDK builds all three from your code.
- Python
- TypeScript
import json
import logging
import os
from pathlib import Path
from typing import Annotated
from mcp.server import MCPServer
from pydantic import Field
# Logs go to stderr. Anything written to stdout corrupts the stdio transport.
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("bookmarks")
BOOKMARKS_FILE = Path(
os.environ.get("BOOKMARKS_FILE", Path.home() / ".bookmarks-mcp.json")
)
mcp = MCPServer("bookmarks")
def load_bookmarks() -> list[dict]:
if not BOOKMARKS_FILE.exists():
return []
return json.loads(BOOKMARKS_FILE.read_text())
def save_bookmarks(bookmarks: list[dict]) -> None:
BOOKMARKS_FILE.write_text(json.dumps(bookmarks, indent=2))
@mcp.tool()
def save_bookmark(
url: Annotated[str, Field(description="Full URL of the page, including https://")],
title: Annotated[str, Field(description="Short human-readable title for the page")],
tags: Annotated[
list[str] | None,
Field(description='Optional lowercase tags, e.g. ["recipes", "python"]'),
] = None,
) -> str:
"""Save a web page to the user's bookmarks.
Use this when the user asks to save, bookmark or remember a link.
"""
bookmarks = load_bookmarks()
if any(b["url"] == url for b in bookmarks):
return f"Already saved: {url}"
bookmarks.append({"url": url, "title": title, "tags": tags or []})
save_bookmarks(bookmarks)
logger.info("Saved bookmark %s", url)
return f"Saved '{title}'. Total bookmarks: {len(bookmarks)}"
@mcp.tool()
def search_bookmarks(
query: Annotated[
str, Field(description="One or more words to look for, case-insensitive")
],
) -> str:
"""Search saved bookmarks by words in the title, URL or tags."""
words = query.lower().split()
matches = [
b
for b in load_bookmarks()
if all(
w in " ".join([b["title"], b["url"], *b["tags"]]).lower() for w in words
)
]
if not matches:
return f"No bookmarks match '{query}'."
return "\n".join(f"- {b['title']}: {b['url']}" for b in matches)
if __name__ == "__main__":
mcp.run(transport="stdio")
MCPServer turns each @mcp.tool() function into a tool. The function name becomes the tool name, the docstring becomes the description, and the type hints become the input schema. Wrapping a parameter in Annotated[..., Field(description=...)] attaches a description to that parameter in the schema. If you list parameters under Args: in the docstring instead, that text stays inside the tool description and the parameters themselves go out with no description.
#!/usr/bin/env node
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
const BOOKMARKS_FILE =
process.env.BOOKMARKS_FILE ?? join(homedir(), ".bookmarks-mcp.json");
interface Bookmark {
url: string;
title: string;
tags: string[];
}
function loadBookmarks(): Bookmark[] {
if (!existsSync(BOOKMARKS_FILE)) return [];
return JSON.parse(readFileSync(BOOKMARKS_FILE, "utf8")) as Bookmark[];
}
function saveBookmarks(bookmarks: Bookmark[]): void {
writeFileSync(BOOKMARKS_FILE, JSON.stringify(bookmarks, null, 2));
}
function text(value: string) {
return { content: [{ type: "text" as const, text: value }] };
}
// serveStdio calls this whenever it needs a server instance,
// so it must return a new McpServer every time.
function createServer() {
const server = new McpServer({ name: "bookmarks", version: "1.0.0" });
server.registerTool(
"save_bookmark",
{
description:
"Save a web page to the user's bookmarks. Use this when the user asks to save, bookmark or remember a link.",
inputSchema: z.object({
url: z.string().url().describe("Full URL of the page, including https://"),
title: z.string().describe("Short human-readable title for the page"),
tags: z
.array(z.string())
.optional()
.describe('Optional lowercase tags, e.g. ["recipes", "python"]'),
}),
},
async ({ url, title, tags }) => {
const bookmarks = loadBookmarks();
if (bookmarks.some((b) => b.url === url)) {
return text(`Already saved: ${url}`);
}
bookmarks.push({ url, title, tags: tags ?? [] });
saveBookmarks(bookmarks);
console.error(`Saved bookmark ${url}`);
return text(`Saved '${title}'. Total bookmarks: ${bookmarks.length}`);
},
);
server.registerTool(
"search_bookmarks",
{
description: "Search saved bookmarks by words in the title, URL or tags.",
inputSchema: z.object({
query: z.string().describe("One or more words to look for, case-insensitive"),
}),
},
async ({ query }) => {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
const matches = loadBookmarks().filter((b) => {
const haystack = [b.title, b.url, ...b.tags].join(" ").toLowerCase();
return words.every((w) => haystack.includes(w));
});
if (matches.length === 0) return text(`No bookmarks match '${query}'.`);
return text(matches.map((b) => `- ${b.title}: ${b.url}`).join("\n"));
},
);
return server;
}
// Logs go to stderr. Anything written to stdout corrupts the stdio transport.
serveStdio(createServer, {
onerror: (error) => console.error("Bookmarks server error:", error),
});
console.error("Bookmarks MCP server running on stdio");
registerTool takes the tool name, its description and a Zod schema, then the handler that runs when the model calls it. Each .describe() becomes that parameter's description in the schema the model sees.
serveStdio works out which protocol version each AI app speaks, so the same server answers apps on the current 2026-07-28 revision and apps still using the older 2025-era handshake. It calls createServer() whenever it needs a server instance, which is why the function returns a new McpServer each time. Starting the server with server.connect(new StdioServerTransport()) still compiles, but a server started that way only answers 2025-era apps.
Build it:
npm run build
Over stdio, standard output carries the protocol messages. A stray print() or console.log() puts text that isn't a protocol message into that stream, so the client may report an error or disconnect. Log with logging in Python or console.error() in Node, which both write to stderr.
3. Test it with MCP Inspector
MCP Inspector connects to your server the same way an AI app does. Its command-line mode is the fastest check. List the tools with the current protocol revision:
- Python
- TypeScript
npx @modelcontextprotocol/inspector --cli uv run bookmarks.py --protocol-era modern --method tools/list
npx @modelcontextprotocol/inspector --cli node build/index.js --protocol-era modern --method tools/list
You should see both tools, each with the parameter descriptions you wrote:
{
"tools": [
{
"name": "save_bookmark",
"description": "Save a web page to the user's bookmarks.\n\nUse this when the user asks to save, bookmark or remember a link.\n",
"inputSchema": {
"type": "object",
"properties": {
"url": {
"description": "Full URL of the page, including https://",
"title": "Url",
"type": "string"
},
...
The Inspector defaults to the legacy era, which only exercises the older handshake. Run the same command again with --protocol-era legacy to confirm older apps can connect too. Both runs should list save_bookmark and search_bookmarks.
Then call a tool:
npx @modelcontextprotocol/inspector --cli uv run bookmarks.py --protocol-era modern \
--method tools/call --tool-name save_bookmark \
--tool-arg url=https://modelcontextprotocol.io --tool-arg title="MCP docs"
For TypeScript, replace uv run bookmarks.py with node build/index.js.
Leave out --cli to open the Inspector's web interface, where you can browse tools and fill in parameters with a form. See Test with MCP Inspector.
4. Connect it to Claude Desktop
Open Claude Desktop's config file in a text editor, creating it if it doesn't exist:
| OS | Config file |
|---|---|
| macOS | ~/Library/Application Support/Claude/claude_desktop_config.json |
| Windows | %APPDATA%\Claude\claude_desktop_config.json |
| Linux | ~/.config/Claude/claude_desktop_config.json |
Add your server under the mcpServers key. Use the absolute path to your project folder; run pwd in it on macOS or Linux, or cd on Windows, to get it.
- Python
- TypeScript
{
"mcpServers": {
"bookmarks": {
"command": "uv",
"args": [
"--directory",
"/ABSOLUTE/PATH/TO/bookmarks",
"run",
"bookmarks.py"
]
}
}
}
If Claude Desktop can't find uv, put its full path in command. Get it with which uv on macOS or Linux, or where uv on Windows.
{
"mcpServers": {
"bookmarks": {
"command": "node",
"args": ["/ABSOLUTE/PATH/TO/bookmarks/build/index.js"]
}
}
}
On Windows, write paths with double backslashes (C:\\Users\\you\\bookmarks) or forward slashes. Save the file, then quit Claude Desktop completely and reopen it; closing the window isn't enough.
Ask Claude to "bookmark modelcontextprotocol.io as MCP docs, tagged mcp". It calls save_bookmark, asking your permission first unless you've already allowed the tool, then confirms the save. In a new chat, ask "what have I bookmarked about mcp?" and it will call search_bookmarks.
The same server works in any app that runs local MCP servers. Each app keeps its config in a different place: see Claude Code, Cursor, VS Code and Codex.
Common problems
The server doesn't appear in Claude Desktop.
The config file usually has a JSON syntax error, such as a trailing comma or a missing brace. Check it with a JSON validator, then fully quit and reopen the app. Claude Desktop writes MCP logs to ~/Library/Logs/Claude on macOS and %APPDATA%\Claude\logs on Windows. Follow them while you restart the app:
tail -n 20 -F ~/Library/Logs/Claude/mcp*.log
spawn uv ENOENT or spawn node ENOENT in the logs.
The app couldn't find the command. Desktop apps don't always load your shell's PATH, so put the full path from which uv or which node in command.
The server starts, then disconnects straight away.
Something is writing to stdout. Look for print() or console.log() calls, including in libraries you import, and switch them to stderr logging.
ModuleNotFoundError: No module named 'mcp'.
The app is running a different Python from the one you installed into. Launch the server through uv --directory ... run as shown, so uv uses the project's environment.
The model doesn't call your tool. The description doesn't match how people phrase the request. Say plainly what the tool does and when to use it, as the "Use this when the user asks to save, bookmark or remember a link" line does here. Tool names and descriptions covers this in depth.
TypeScript: Inspector lists no tools in modern era but works in legacy.
The server was started with server.connect(new StdioServerTransport()). Switch to serveStdio(createServer) as shown in step 2.
For more errors and fixes, see Fix MCP connection errors.
FAQ
Which language should I build an MCP server in?
Use the language you already know. Python and TypeScript have the most examples and tutorials. The official SDKs for TypeScript, Python, C#, Go, Rust and Ruby are rated Tier 1 for completeness and maintenance, and there are also official SDKs for Java, Swift, PHP and Kotlin. The protocol is the same whichever you pick, so any AI app can use your server. This library has the same server in Go, C#, Java and Rust.
Do I need to host an MCP server online?
No. A server that runs over stdio, like the one in this guide, runs on your own computer and the AI app starts it for you. You only need hosting if other people, or web-based AI apps like ChatGPT, need to reach it. See Local vs remote MCP servers.
What is the difference between an MCP server and an API?
An API is built for developers to call from code. An MCP server is built for AI models to call: it describes each tool in plain language and uses a standard protocol, so any MCP-compatible app can find and use the tools without custom integration code. Many MCP servers call an API behind the scenes. See MCP vs API.
Should I use FastMCP instead of the official Python SDK?
Both work. The official SDK, used here, is maintained alongside the protocol. FastMCP is a separate, independently maintained framework with a larger feature set. See the FastMCP guide for when it is worth it.
How many tools should an MCP server have?
As few as covers the job well. Every tool description takes up space in the model's context and gives it one more option to choose between. See How many tools is too many.
- Test with MCP InspectorUse the Inspector web interface to debug tools, resources and prompts.
- Write tools the model picks correctlyNames and descriptions that lead to the right tool call.
- Tools, resources and promptsWhat else an MCP server can offer besides tools.
- Local vs remote serversWhen to move from stdio to Streamable HTTP.
- Deploy a remote MCP serverHost it so web apps and other people can use it.
- Build an MCP clientWrite the other side: connect to this server and call its tools from your own code.