Every AI assistant that can read your database, file a ticket, or check a deployment does it through a tool integration. The Model Context Protocol is the open standard that makes those integrations portable, so a tool you write once works in every host that speaks it.
The short answer: you build an MCP server by installing an official SDK, writing a normal function, and marking it as a tool with a decorator. The Python SDK titles its quickstart "A server in 15 lines", and that is the job: one import, one server object, one type-hinted function.
This guide builds a real MCP server twice: first in Python, wrapping a PostgreSQL database in read-only query tools, then in TypeScript, wrapping an HTTP API. Along the way it covers tool parameter schemas, the three ways a tool can fail, how to test the server locally before any host sees it, and how to choose between the two transports the specification defines.
What Do You Need Before You Start?
Very little. There is no account to create and no key to obtain.
For the Python path you need Python 3.10 or later, the floor the official SDK sets. For the TypeScript path you need Node.js 20 or later. If you plan to use the MCP Inspector for local testing, and you should, that tool is a Node application and currently requires Node 22.19.0 or later, so install a recent Node even if you are writing Python.
You also need something worth exposing. A server that returns a greeting teaches you the decorator syntax and nothing else. The two examples here wrap things people actually have: a database and an HTTP API.
What Does an MCP Server Actually Do?
An MCP server is a process that speaks JSON-RPC 2.0 over a transport, and answers a small, fixed set of methods. The one that matters most is tools/list, which advertises what the server can do, and tools/call, which runs one of those things.
The specification defines three kinds of thing a server can offer:
- Tools are functions the model can execute. Search a catalog, open a pull request, run a query.
- Resources are data the model or the user can read, addressed by URI. A config file, a document, a table listing.
- Prompts are templated messages a user can invoke deliberately, usually surfaced as slash commands in the host.
Most servers are mostly tools, and this guide focuses there. The current protocol revision is 2026-07-28, published at modelcontextprotocol.io, and both official SDKs implement it along with every earlier revision.
The part that trips people up is what you do not write. You do not write a JSON-RPC parser, a capability negotiation handshake, or a JSON Schema for your tool arguments. The SDK derives the schema from your type hints and rejects malformed calls before your code runs. Your job is the function body.
How Do You Build an MCP Server in Python?
Install the official Python SDK. The cli extra adds the mcp command line tool, which you will want for local testing.
mkdir db-mcp-server && cd db-mcp-server
python3 -m venv .venv && source .venv/bin/activate
pip install "mcp[cli]" "psycopg[binary]"One note on versions. The SDK's version 2 line is the current stable release, and pip install mcp now installs 2.x. If you are joining an existing project written against version 1, pin it explicitly with mcp>=1.28,<2 until you migrate, because the class names changed.
The server
Create server.py. This server gives a model read-only visibility into a PostgreSQL database: it can list tables, inspect their columns, and sample rows, and it cannot write anything.
import os
from typing import Annotated, Any
import psycopg
from psycopg import sql
from psycopg.rows import dict_row
from pydantic import Field
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer(
"Analytics DB",
instructions="Read-only inspection of the analytics database. Call list_tables first.",
)
DATABASE_URL = os.environ["DATABASE_URL"]
def connect() -> psycopg.Connection:
"""Open a read-only connection with a hard statement timeout."""
conn = psycopg.connect(
DATABASE_URL,
row_factory=dict_row,
options="-c statement_timeout=5000",
)
conn.read_only = True
return conn
def require_table(conn: psycopg.Connection, table: str) -> None:
found = conn.execute(
"SELECT 1 FROM information_schema.tables "
"WHERE table_schema = 'public' AND table_name = %s",
(table,),
).fetchone()
if not found:
raise ToolError(
f"No table named {table!r} in the public schema. Call list_tables to see what exists."
)
@mcp.tool()
def list_tables() -> list[str]:
"""List every table in the public schema."""
with connect() as conn:
rows = conn.execute(
"SELECT table_name FROM information_schema.tables "
"WHERE table_schema = 'public' ORDER BY table_name"
).fetchall()
return [row["table_name"] for row in rows]
@mcp.tool()
def describe_table(
table: Annotated[str, Field(description="Table name in the public schema.")],
) -> list[dict[str, Any]]:
"""Return the columns of one table with their types and nullability."""
with connect() as conn:
require_table(conn, table)
return conn.execute(
"SELECT column_name, data_type, is_nullable "
"FROM information_schema.columns "
"WHERE table_schema = 'public' AND table_name = %s "
"ORDER BY ordinal_position",
(table,),
).fetchall()
@mcp.tool()
def sample_rows(
table: Annotated[str, Field(description="Table to read from.")],
limit: Annotated[int, Field(ge=1, le=200, description="Maximum rows to return.")] = 20,
) -> list[dict[str, Any]]:
"""Return up to `limit` rows from a table, for inspecting shape and content."""
with connect() as conn:
require_table(conn, table)
query = sql.SQL("SELECT * FROM {} LIMIT %s").format(sql.Identifier(table))
return conn.execute(query, (limit,)).fetchall()
if __name__ == "__main__":
mcp.run()Three details in there are worth calling out, because they are the difference between a demo and something you would connect to a real database.
The connection is read-only twice over. conn.read_only = True starts every transaction as read-only at the PostgreSQL level, so a write fails in the server, not in your Python. Back that with a database role that has SELECT and nothing else, and the tool surface is not the only thing standing between a confused model and your data.
Table names are identifiers, not parameters. You cannot pass a table name as a query parameter, so sample_rows composes it with psycopg.sql.Identifier, which quotes and escapes it correctly. String formatting a model-supplied table name into SQL is the injection bug this API exists to prevent.
There is a statement timeout. statement_timeout=5000 caps any single query at five seconds. A model exploring an unfamiliar schema will eventually ask for something expensive, and a tool call that hangs is worse than one that fails.
Notice also what is not here: no raw SQL tool. Exposing a run_sql(query: str) tool is tempting and is where read-only database servers usually go wrong, because the read-only guarantee then rests entirely on the database role. If you do add one, make the role the enforcement and treat the tool description as documentation, not as a control.
How Do You Define Tool Parameters and Schemas?
This is where MCP gets pleasant. Your type hints are the schema.
The SDK reads the function name as the tool name, the docstring as the description the model reads, and the annotations as the input schema. From describe_table above it generates and advertises this during tools/list:
{
"type": "object",
"properties": {
"table": {
"title": "Table",
"type": "string",
"description": "Table name in the public schema."
}
},
"required": ["table"],
"title": "describe_tableArguments"
}MCP treats a schema with no $schema key as JSON Schema 2020-12, which is what Pydantic emits, so there is nothing to configure.
Three patterns cover almost everything you will need:
- A default value makes an argument optional.
limit: int = 20leaveslimitout ofrequiredand puts"default": 20in the schema. Annotated[..., Field(...)]adds descriptions and constraints.Field(ge=1, le=200)becomes"minimum": 1, "maximum": 200, and the SDK enforces it. A call withlimit=999comes back as a tool error readingInput should be less than or equal to 200before your function is ever entered, and the model reads that and retries with a valid number.Literal["a", "b"]becomes an enum, and a PydanticBaseModelparameter becomes a nested object your function receives already validated.
Write the constraint once and you get self-correcting behavior for free. This is the single highest-leverage habit in MCP server design: every bound you express in the schema is a class of failure the model can fix itself.
How Do You Handle Errors in a Tool?
A tool can fail in three ways and the SDK treats each differently. Choosing correctly is most of what separates a server a model can work with from one it gets stuck on.
Raise ToolError when the model could fix it. The request succeeds, the result comes back with is_error set to True, and your message lands in the content the model reads. A missing table, a search with no hits, an upstream API that timed out. The model reads the sentence, adjusts, and calls again. That is what require_table above does, and why its message ends by naming the tool to call instead.
Raise MCPError when the request itself should be rejected. This is a protocol error: it propagates, the whole tools/call fails with a JSON-RPC error, and there is no result for the model to read at all. The host application gets it instead.
from mcp import MCPError
from mcp.types import INVALID_PARAMS
raise MCPError(code=INVALID_PARAMS, message="Server is not configured with a database.")Anything else is a crash. The model learns only that the call failed, and your log gets the traceback.
One rule matters more than the taxonomy: never return an error message from a tool. A returned string carries is_error=False, so the model and every client UI read it as the answer. The flag is the signal, so raise.
How Do You Build the Same Server in TypeScript?
The official TypeScript SDK ships its version 2 line as split packages: @modelcontextprotocol/server for servers and @modelcontextprotocol/client for clients. Tool schemas use Standard Schema, so Zod, Valibot, or ArkType all work.
This server wraps an HTTP API. It gives a model read access to open issues in a GitHub repository, with the token supplied by the environment rather than by the caller.
mkdir issues-mcp-server && cd issues-mcp-server
npm init -y && npm pkg set type=module
npm install @modelcontextprotocol/server zod tsxThe type=module line matters, because the SDK ships ES modules only. Create src/index.ts:
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
const API = 'https://api.github.com';
interface Issue {
number: number;
title: string;
state: string;
html_url: string;
pull_request?: unknown;
}
function apiHeaders(): Record<string, string> {
const token = process.env.GITHUB_TOKEN;
if (!token) throw new Error('GITHUB_TOKEN is not set');
return {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'issues-mcp-server/1.0'
};
}
export function createServer(): McpServer {
const server = new McpServer({ name: 'issues', version: '1.0.0' });
server.registerTool(
'list_open_issues',
{
description: 'List open issues in a GitHub repository, newest first',
inputSchema: z.object({
owner: z.string().describe('Repository owner, for example "modelcontextprotocol"'),
repo: z.string().describe('Repository name, for example "python-sdk"'),
limit: z.number().int().min(1).max(50).default(10)
})
},
async ({ owner, repo, limit }) => {
const path = `${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
const res = await fetch(`${API}/repos/${path}/issues?state=open&per_page=${limit}`, {
headers: apiHeaders()
});
if (!res.ok) {
return {
content: [{ type: 'text', text: `GitHub API error: HTTP ${res.status}` }],
isError: true
};
}
// The issues endpoint also returns pull requests. Filter them out.
const issues = ((await res.json()) as Issue[]).filter(i => !i.pull_request);
if (issues.length === 0) {
return { content: [{ type: 'text', text: `No open issues in ${path}.` }] };
}
const lines = issues.map(i => `#${i.number} ${i.title}\n${i.html_url}`);
return { content: [{ type: 'text', text: lines.join('\n\n') }] };
}
);
return server;
}
void serveStdio(createServer);
console.error('issues MCP server running on stdio');registerTool takes a name, a config object, and an async handler. The Zod schema is the only schema you write: the SDK derives the JSON Schema the model sees, validates arguments before the handler runs, and infers the handler's parameter types. A call with limit: 500 is rejected with a message the model can act on, exactly as in the Python version.
Two things differ from Python and both catch people out. First, the server is built by a factory function, not a module-level singleton, because the HTTP transport calls it once per request. Second, console.log corrupts the protocol on stdio, because stdout is the wire. Log to stderr with console.error. The Python SDK diverts flushed stdout writes to stderr for you, but the rule is the same in spirit: use a logger, not a print.
stdio or HTTP: Which Transport Should Your Server Use?
The specification defines exactly two standard transports, and the choice is the only real architectural decision in a small MCP server.
| Concern | stdio | Streamable HTTP |
|---|---|---|
| How it starts | The host launches your file as a subprocess | You run it, it listens on a port |
| Where it runs | The same machine as the client | Anywhere reachable over the network |
| Python | mcp.run() | mcp.run(transport="streamable-http", host="0.0.0.0", port=8080) |
| TypeScript | serveStdio(createServer) | createMcpHandler(createServer) mounted on a route |
| Endpoint | None. stdin and stdout | One URL, /mcp by default |
| Client config | A command and its arguments | A URL |
| Who can use it | You, on this laptop | Anyone you give the URL and a token to |
| Auth | Inherits the process that launched it | A bearer token you verify in front of the handler |
| Logging | stderr only, stdout is the protocol | Ordinary HTTP logs |
| What you deploy | Nothing | A container that stays up, plus DNS and TLS |
The older SSE transport still appears in some documentation. It was superseded by Streamable HTTP in the 2025-03-26 protocol revision and exists only for clients that have not moved. Do not build anything new on it.
Switching a finished server from one to the other is a one-line change, which is the point of writing tools against the SDK rather than against a transport. In Python:
if __name__ == "__main__":
mcp.run(transport="streamable-http", host="0.0.0.0", port=8080)Two things live in that line. The default host is 127.0.0.1, which inside a container means nothing outside the container can reach you, so 0.0.0.0 is mandatory when you deploy. And changing it silently drops a protection you never configured: the SDK auto-enables DNS rebinding protection only when the bind address is loopback, allowlisting 127.0.0.1, localhost, and ::1. Bind 0.0.0.0 and no Host or Origin check runs at all, even though the specification says a server must validate Origin. Pass the settings yourself and name the hostname you actually serve:
from mcp.server.transport_security import TransportSecuritySettings
security = TransportSecuritySettings(
allowed_hosts=["mcp.example.com", "mcp.example.com:*"],
allowed_origins=["https://mcp.example.com"],
)
mcp.run(transport="streamable-http", host="0.0.0.0", port=8080, transport_security=security)Get that allowlist wrong and the failure is quiet in a particular way. A rejected Host comes back as 421 Misdirected Request and a rejected Origin as 403 Forbidden, both plain HTTP responses rather than JSON-RPC errors, so the client reports a generic transport failure and the value that was rejected appears only in your server log. A newly deployed MCP server that refuses everything is an allowlist typo until proven otherwise.
How Do You Test an MCP Server Locally?
Do not debug through a chat window. The MCP Inspector is the official tool for this: it connects to your server the way a host would, lists the tools, renders a form from your schema, and shows you the raw protocol traffic.
For the Python server, the SDK wires it up for you:
uv run mcp dev server.pyThat launches server.py as a subprocess over stdio, exactly as a real host would, and opens the Inspector in your browser. Go to the Tools tab and call list_tables. The form you see was built entirely from your type hints, and so is every other client's.
For the TypeScript server, point the Inspector at the command that starts it:
npx @modelcontextprotocol/inspector npx tsx src/index.tsThe Inspector also has a scriptable CLI mode (--cli) and a terminal UI (--tui), which is what you want in CI or in a fast edit-and-check loop.
If the server is already listening over HTTP, plain curl is enough to prove the endpoint answers:
curl -s -X POST http://127.0.0.1:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'A well-formed tools/list result means the transport, the routing, and the schema generation all work. Everything after that is your tool logic.
How Do You Connect It to Claude Desktop?
For a local stdio server, the host needs one thing: the command that starts it. The Python SDK writes that entry for you.
uv run mcp install server.py --name "Analytics DB" -f .envIt resolves your path to an absolute one, reads the server's name, and writes into claude_desktop_config.json, which lives at ~/Library/Application Support/Claude/claude_desktop_config.json on macOS and %APPDATA%\Claude\claude_desktop_config.json on Windows. The entry it writes looks like this:
{
"mcpServers": {
"Analytics DB": {
"command": "/absolute/path/to/uv",
"args": [
"run",
"--frozen",
"--with",
"mcp[cli]==2.0.0",
"mcp",
"run",
"/absolute/path/to/server.py"
],
"env": {
"DATABASE_URL": "postgresql://reader:...@localhost:5432/analytics"
}
}
}
}You can write that file by hand, and for the TypeScript server you will, since mcp install is a Python SDK command. Two rules apply either way. Use absolute paths, because the host launches your server from its own working directory, not yours. And put credentials in the env block, because Claude Desktop starts your server in a fresh process where your shell's environment does not exist. Fully quit the app, not just its window, and reopen it.
Other hosts take the same launch command in their own config. Claude Code registers it with claude mcp add <name> -- <command>, and editors like VS Code and Cursor each have their own file with the same shape.
From localhost to Your Team
Everything above runs on your machine. A stdio server is launched by the host that uses it, which means the only people who can use your server are people who have your source code, your credentials, and your Python environment. The moment a teammate, a CI job, or a scheduled agent needs the same tools, the answer stops being a command and becomes a URL: the same server object served over Streamable HTTP, running somewhere that stays up, behind HTTPS and a token you verify. That is a deployment question rather than an MCP question, and deploying an MCP server to production walks through it end to end.
Frequently Asked Questions
Which language should I use to build an MCP server?
Use whichever language the thing you are wrapping already lives in, because your tools will mostly be calls into existing code. Python and TypeScript have the most mature official SDKs and the best documentation, and there are official SDKs for several other languages. If you have no constraint, Python's type-hints-as-schema approach is the shortest path from idea to working tool.
Do I need an Anthropic API key to build an MCP server?
No. An MCP server is an ordinary program that speaks a protocol. It never calls a model and needs no model provider credentials. The host on the other side is the thing talking to a model, and it brings its own key. The only credentials your server needs are for the systems it wraps, such as a database URL or an API token.
How do I debug an MCP server?
Use the MCP Inspector rather than a chat client, because it shows the raw JSON-RPC traffic alongside the rendered tool forms. On stdio, remember that stdout carries the protocol, so log with console.error in TypeScript and the logging module in Python. A print that reaches stdout will corrupt the stream and produce failures that look like protocol bugs.
Can one MCP server expose many tools?
Yes, and most useful servers do. There is no protocol limit. The practical limit is the host's context: every tool's name, description, and schema is sent to the model on tools/list, so a server with sixty overlapping tools makes the model worse at choosing. Group related capabilities into one server and keep each tool's purpose distinct.
How do I keep secrets out of my MCP server code?
Read them from environment variables and never commit them. On stdio the host supplies them, either through the env block in its config file or through mcp install -v KEY=value and -f .env. On a deployed HTTP server they come from the platform's environment variable settings. The environment variables and secrets guide covers rotation and scoping in more depth.
What is the difference between a tool, a resource, and a prompt?
A tool is a function the model decides to call. A resource is data addressed by URI that the client or user pulls in as context, closer to a file than a function. A prompt is a templated message the user invokes deliberately, usually as a slash command. Tools are the ones models use autonomously, which is why most servers are mostly tools.
Where to Go Next
Building an MCP server is a small job that stays small if you let the SDK do its work. Write ordinary functions, express your constraints in the type signature, raise ToolError when the model can recover, and let the SDK turn all of that into protocol. The parts that deserve your attention are the ones this guide spent its space on: what the tools are allowed to touch, how tightly the arguments are bounded, and what a failure tells the model.
Once the server is worth sharing, it needs to live somewhere. Out Plane runs containers from a GitHub repository or a registry image, with automatic HTTPS, environment variable groups for the credentials your tools need, managed PostgreSQL in the same region as the app, and a browser shell for inspecting a running instance when a tool misbehaves. Everything runs in Nuremberg, Germany, and instances stay warm, so the first tool call after a quiet hour is as fast as any other.
Read what an MCP server is for the background, then deploy the one you just built. Starter and Pro both open with a 14 day free trial at console.outplane.com, and the pricing page has current figures.
