Skip to main content

How to Build an MCP Server

Short answer

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.

Tested withMCP spec 2026-07-28Python SDK (mcp) 2.3.0TypeScript SDK (@modelcontextprotocol/server) 2.3.1MCP Inspector 2.11.0Node.js 22Python 3.14Checked 19 Oct 2026

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 mcp package.
  • 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​

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.

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.

bookmarks.py
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.

Never write to stdout

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:

npx @modelcontextprotocol/inspector --cli uv run bookmarks.py --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.

tip

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:

OSConfig 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.

claude_desktop_config.json
{
"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.

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.