First Principles · 11 min mission
Build an MCP Server From Scratch
Write a working MCP server in TypeScript or Python, test it with the Inspector, and register it with your agent.
On this page
An MCP server is a small program that exposes capabilities behind a standard JSON-RPC interface; any MCP host — Claude Code, Codex, Gemini CLI, Claude Desktop — can then connect to it. This guide builds one from scratch: define the three capability kinds, pick a transport, write a minimal runnable server in both official SDKs, test it with the MCP Inspector, and register it with each CLI.
The three capabilities, and who controls each
A server exposes up to three capability kinds, distinguished by who decides when each one runs: the model calls tools, the application pulls in resources, the user invokes prompts.
| Capability | Controlled by | What it is | JSON-RPC methods |
|---|---|---|---|
| Tools | Model | Functions the model calls — query an API, write a row, modify a file. Schema-defined; require user consent before execution. | tools/list, tools/call |
| Resources | Application | Read-only data exposed by URI — file contents, a DB schema, API docs. The app decides whether to feed them to the model. | resources/list, resources/read |
| Prompts | User | Instruction templates with arguments, invoked explicitly (e.g. a slash command). | prompts/list, prompts/get |
Choosing a transport
Pick how a host talks to your server before writing handlers. The spec defines two standard transports; clients SHOULD support stdio whenever possible. stdio launches your server as a subprocess and exchanges newline-delimited JSON-RPC over stdin/stdout — no port, no auth. Streamable HTTP exposes a single endpoint path (e.g. https://example.com/mcp) serving POST + GET, for remote or shared servers; it replaces the deprecated two-endpoint HTTP+SSE transport from the 2024-11-05 revision (Streamable HTTP may still use SSE internally on its one endpoint — that is not the deprecated thing).
stdio vs. Streamable HTTP
stdio (local subprocess)
Client spawns your server and talks over stdin/stdout. No HTTP server, no port, no auth. Best for local tools one user runs. The catch: stdout is reserved for protocol messages — see the next callout.
Streamable HTTP (remote)
One /mcp endpoint serving POST + GET. For shared/remote servers and multiple clients. Carries MCP-Session-Id and MCP-Protocol-Version: 2025-11-25 headers, and requires Origin validation + auth before you expose it.
Build a minimal TypeScript server (stdio)
Install the SDK
Run
npm install @modelcontextprotocol/sdk zod@3thennpm install -D @types/node typescript. The official quickstart pinszod@3; SDK 1.29.0 also acceptszod ^4.0.Mark the package as ESM
Add
"type": "module"topackage.json— the SDK ships ES modules and imports use.jsextensions.Register a tool and connect
Use
new McpServer({ name, version }), thenserver.registerTool(name, config, handler), thenawait server.connect(new StdioServerTransport()). See the code block below.Build and run
Compile with
tsc, then runnode build/index.js. The only line that prints usesconsole.error, so the startup diagnostic lands on stderr.
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.registerTool(
"get_alerts",
{
description: "Get weather alerts for a US state",
// inputSchema is a MAP of Zod schemas, not a wrapped object
inputSchema: {
state: z.string().length(2).describe("Two-letter state code (e.g. CA, NY)"),
},
},
async ({ state }) => {
// ...do real work here (call an API, read a file)...
return { content: [{ type: "text", text: `Active alerts for ${state}` }] };
},
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
// stderr, NEVER stdout — stdout is the JSON-RPC channel
console.error("Weather MCP Server running on stdio");
}
main().catch((e) => {
console.error(e);
process.exit(1);
});Adding resources and prompts
The same server exposes resources and prompts via parallel register* calls. A resource is keyed by a URI — a fixed one (direct resource) or a parameterized ResourceTemplate filled at read time. A prompt declares an argument schema and returns messages to inject. Binary resources use a base64 blob field instead of text, and the SDK fires list_changed notifications automatically on register/remove/enable/disable.
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
// Direct (fixed-URI) resource
server.registerResource(
"config",
"config://app",
{ title: "Application Config", description: "App configuration", mimeType: "text/plain" },
async (uri) => ({
contents: [{ uri: uri.href, text: "App configuration here" }],
}),
);
// Dynamic resource via a URI template
server.registerResource(
"user-profile",
new ResourceTemplate("users://{userId}/profile", { list: undefined }),
{ title: "User Profile", mimeType: "application/json" },
async (uri, { userId }) => ({
contents: [{ uri: uri.href, text: JSON.stringify(await getUser(userId)) }],
}),
);
// Prompt with an argument schema (surfaces as a slash command in hosts)
server.registerPrompt(
"review-code",
{ title: "Code Review", description: "Review code for best practices", argsSchema: { code: z.string() } },
({ code }) => ({
messages: [
{ role: "user", content: { type: "text", text: `Please review this code:\n\n${code}` } },
],
}),
);Build the same server in Python (FastMCP)
Scaffold with uv
Run
uv init weather && cd weather, thenuv venv && source .venv/bin/activate.Add the SDK with the CLI extra
Run
uv add "mcp[cli]" httpx. The[cli]extra ships themcpcommand.Decorate plain functions
FastMCP infers each tool’s input schema from your type hints and docstring — use
@mcp.tool(),@mcp.resource("scheme://{param}"), and@mcp.prompt(). See the code block below.Run it
Call
mcp.run(transport="stdio")(switch to"streamable-http"for remote), thenuv run weather.py.
import sys
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("weather")
@mcp.tool()
async def get_alerts(state: str) -> str:
"""Get weather alerts for a US state.
Args:
state: Two-letter US state code (e.g. CA, NY)
"""
# ...do real work...
return f"Active alerts for {state}"
@mcp.resource("greeting://{name}")
def get_greeting(name: str) -> str:
"""Get a personalized greeting"""
return f"Hello, {name}!"
@mcp.prompt()
def greet_user(name: str, style: str = "friendly") -> str:
"""Generate a greeting prompt"""
return f"Please write a {style} greeting for {name}."
def main():
# log to stderr — never print() to stdout on a stdio server
print("Weather MCP Server running on stdio", file=sys.stderr)
mcp.run(transport="stdio") # switch to "streamable-http" for remote
if __name__ == "__main__":
main()| Command | What it does |
|---|---|
uv run mcp dev weather.py | Runs the server and launches the MCP Inspector against it in one step (add deps with --with pandas). |
uv run mcp install weather.py | Installs the server into Claude Desktop (supports a custom name + env vars). |
uv run mcp run weather.py | Runs the server directly (FastMCP servers only, not the low-level Server). |
Scaffold a minimal MCP server
MCP server scaffold
Stand up a minimal MCP server. Pick a language, choose which primitives it exposes — tools, resources, prompts — and a transport. The entrypoint and the setup commands regenerate together against the real SDK, so what you copy actually runs.
Language
The SDK and entrypoint file differ — the scaffold adapts to your choice.
Primitives
What the server exposes to the host. Pick at least one — most servers start with tools.
Transport
How the host reaches the server. stdio launches it as a subprocess; Streamable HTTP serves it over a URL.
Local subprocess over stdin/stdout. The default for desktop hosts.
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",}); // A tool: a function the model can call to take action.server.registerTool( "echo", { title: "Echo", description: "Echo a message back to the caller.", inputSchema: { message: z.string().describe("Text to echo") }, }, async ({ message }) => ({ content: [{ type: "text", text: `You said: ${message}` }], }),); async function main() { const transport = new StdioServerTransport(); await server.connect(transport); // Log to stderr only — stdout carries the JSON-RPC protocol. console.error("MCP server running on stdio");} main().catch((error) => { console.error("Fatal error:", error); process.exit(1);});npm init -ynpm install @modelcontextprotocol/sdk zodnpm install -D typescript tsx @types/nodenpx tsx src/server.tsTypeScript MCP server exposing Tools over stdio.
Test it with the MCP Inspector
The MCP Inspector (@modelcontextprotocol/inspector 0.22.0) is the official test harness: a React UI on port 6274 backed by a proxy on port 6277, both bound to localhost, speaking stdio, SSE, and streamable-http. UI mode gives a clickable list of tools, resources, and prompts. CLI mode (--cli) is scriptable for CI: --method tools/list dumps the catalog, --method tools/call --tool-name <name> --tool-arg key=value invokes one. The proxy requires a bearer token — a random session token is printed at startup and pre-filled in the browser; override it for scripted runs with MCP_PROXY_AUTH_TOKEN. Args after -- go to your server.
Register it with a host
A host needs two facts: how to start your server (a command for stdio, a URL for HTTP) and any env/auth it needs. For stdio registration the recurring sharp edge is the -- separator: host flags (--transport, --env, --scope) go before --; everything after -- is the launch command passed to your server untouched.
Register the server with each CLI
Claude Code
Use
claude mcp addwith a--transportflag and a scope (localdefault,projectshared via.mcp.json, oruser). Stdio:claude mcp add --transport stdio weather -- node build/index.js. Remote:claude mcp add --transport http notion https://mcp.notion.com/mcp(add--header "Authorization: Bearer …"for auth; JSONtypeacceptsstreamable-httpas an alias forhttp). Manage withclaude mcp list/get/remove, and check live status inside a session with/mcp.Codex CLI
Use
codex mcp add; config is stored in~/.codex/config.toml. Stdio:codex mcp add weather -- node build/index.js(put--env VAR=VALUEbefore--). Remote:codex mcp add weather --url https://example.com/mcpwrites astreamable_httptransport; for OAuth servers runcodex mcp login <name>.Gemini CLI
Edit the
mcpServersobject in~/.gemini/settings.json(global) or.gemini/settings.json(project). Stdio entries takecommand,args,env,cwd,timeout, andtrust. For HTTP use"httpUrl": "http://localhost:3000/mcp"(SSE uses"url"). Inspect connected servers with/mcpinside a session.
{
"mcpServers": {
"weather": {
"command": "node",
"args": ["build/index.js"],
"env": { "API_KEY": "$MY_API_TOKEN" },
"cwd": "./server-dir",
"timeout": 30000,
"trust": false
}
}
}| Host | Add a stdio server | Config lives in |
|---|---|---|
| Claude Code | claude mcp add --transport stdio weather -- node build/index.js | ~/.claude.json or .mcp.json (by scope) |
| Codex CLI | codex mcp add weather -- node build/index.js | ~/.codex/config.toml |
| Gemini CLI | Add to the mcpServers object (see JSON above) | ~/.gemini/settings.json |
Knowledge check
Your stdio MCP server connects, but the host immediately reports a JSON parse error and drops the connection. You recently added a `console.log("handling request", args)` line to debug a tool. What is the fix?
Reach the end and this star joins your charted sky.