Quick answer: To build an MCP server, you use an official Model Context Protocol SDK to expose one or more tools, describe each tool with a name, a description, and a JSON Schema for its inputs, and speak JSON-RPC over a transport (stdio for local, HTTP for remote). Clients like Claude Desktop and Cursor then discover your tools with tools/list and run them with tools/call. A minimal working server is about thirty lines of code.
This is a build guide, not a usage guide. If you want to use an existing web-search MCP server, read our MCP web search server guide instead. Here we are writing one.
What an MCP server actually exposes
The Model Context Protocol lets an AI application (the "host") connect to external capabilities through a standard interface. A server can expose three kinds of things:
| Primitive | What it is | Who initiates it |
|---|---|---|
| Tools | Functions the model can call (search, query a database, send a message) | The model decides |
| Resources | Read-only data the host can load into context (files, records, docs) | The application decides |
| Prompts | Reusable prompt templates the user can invoke | The user decides |
Most servers you will write start with tools, because tools are what let the model act. This tutorial builds a tool. If you have never touched the concept, our explainer on what MCP is and why it matters covers the ideas underneath.
The protocol basics
MCP is JSON-RPC 2.0 under the hood. You do not usually write the JSON by hand (the SDK does it for you), but knowing the shape helps when you debug.
A client first negotiates with initialize. Then it asks what the server can do:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
}
The server answers with its tool catalog:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "get_forecast",
"description": "Get the weather forecast for a city.",
"inputSchema": {
"type": "object",
"properties": {
"city": { "type": "string", "description": "City name, e.g. Austin" }
},
"required": ["city"]
}
}
]
}
}
When the model wants to run it, the client sends tools/call:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "get_forecast",
"arguments": { "city": "Austin" }
}
}
Your server executes the function and returns content blocks. That request-and-response loop is the whole protocol. Everything else is convenience on top.
Build a minimal server (Python)
The official Python SDK (the mcp package) ships a high-level helper called FastMCP that turns a decorated function into a fully described tool. Install it:
pip install "mcp[cli]"
Then write the server:
from mcp.server.fastmcp import FastMCP
import httpx
mcp = FastMCP("weather")
@mcp.tool()
def get_forecast(city: str) -> str:
"""Get the weather forecast for a city."""
resp = httpx.get(
"https://api.example-weather.com/v1/forecast",
params={"q": city},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
return f"{city}: {data['summary']}, high {data['high']}C"
if __name__ == "__main__":
mcp.run() # stdio transport by default
The docstring becomes the tool description, and the type hints become the input schema. That is the entire server. mcp.run() speaks stdio, which is what local desktop clients expect.
The same thing in TypeScript
If your stack is Node, the TypeScript SDK (@modelcontextprotocol/sdk) is the equivalent. The exact import paths and helper names move around between releases, so treat this as a shape and check the current docs before you ship:
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: "weather", version: "1.0.0" });
server.tool(
"get_forecast",
{ city: z.string().describe("City name, e.g. Austin") },
async ({ city }) => {
const resp = await fetch(
`https://api.example-weather.com/v1/forecast?q=${encodeURIComponent(city)}`
);
const data = await resp.json();
return {
content: [{ type: "text", text: `${city}: ${data.summary}` }],
};
}
);
await server.connect(new StdioServerTransport());
The zod schema does the same job as the Python type hints: it generates the JSON Schema the client sees in tools/list.
Connect it to Claude Desktop
Claude Desktop launches local servers as subprocesses over stdio. Add your server to claude_desktop_config.json:
{
"mcpServers": {
"weather": {
"command": "python",
"args": ["/absolute/path/to/weather_server.py"]
}
}
}
The config file lives at ~/Library/Application Support/Claude/claude_desktop_config.json on macOS and %APPDATA%\Claude\claude_desktop_config.json on Windows. Restart Claude, and the tool appears in the tools menu.
Connect it to Cursor
Cursor uses the same config shape in its own settings file (~/.cursor/mcp.json or the project .cursor/mcp.json):
{
"mcpServers": {
"weather": {
"command": "python",
"args": ["/absolute/path/to/weather_server.py"]
}
}
}
Reload the window and the server shows up under MCP settings. If you want a broader tour of wiring servers into these editors, our post on MCP web tools for Claude and Cursor walks through it.
Test it before you trust it
Do not debug through the chat UI. The SDK ships an inspector you can run standalone:
npx @modelcontextprotocol/inspector python /path/to/weather_server.py
It opens a local UI where you can call tools/list, fire tools/call with sample arguments, and read the raw JSON-RPC. If a tool works in the inspector but not in Claude, the problem is your config, not your code.
Local (stdio) vs remote (HTTP)
The two transports suit different jobs:
| stdio | Streamable HTTP | |
|---|---|---|
| Runs | As a subprocess on the user's machine | As a network service you host |
| Setup for others | Everyone installs it | Add a URL and a key |
| Best for | Personal tools, filesystem access | Shared team tools, hosted APIs |
| Auth | Inherits the local shell | You handle it (headers, tokens) |
Start with stdio while you build. Move to HTTP when you want teammates to share one server without each of them installing it.
A note on not reinventing the wheel
Building a server is the right move when you are wrapping your own system: an internal database, a proprietary API, a company workflow. But if what you actually need is clean web data (fetch any URL to markdown, or search and get full page content), that server already exists and is hosted for you at link.sc. Point your client at mcp.link.sc, and you get fetch and search tools without writing or maintaining anything. Build when the capability is yours; borrow when it is a solved commodity.
Whatever you build, keep the tool surface small and the descriptions precise. The model chooses tools from their descriptions alone, so a vague description is a bug.
Want a production web-fetch and search server without building one? link.sc hosts it at mcp.link.sc. Start free.