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.

mcpsdktoolsserverpythontypescriptFact-checked 2026-06-15
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.

CapabilityControlled byWhat it isJSON-RPC methods
ToolsModelFunctions the model calls — query an API, write a row, modify a file. Schema-defined; require user consent before execution.tools/list, tools/call
ResourcesApplicationRead-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
PromptsUserInstruction templates with arguments, invoked explicitly (e.g. a slash command).prompts/list, prompts/get
The three server capabilities, framed by who controls when each one runs.

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)

  1. Install the SDK

    Run npm install @modelcontextprotocol/sdk zod@3 then npm install -D @types/node typescript. The official quickstart pins zod@3; SDK 1.29.0 also accepts zod ^4.0.

  2. Mark the package as ESM

    Add "type": "module" to package.json — the SDK ships ES modules and imports use .js extensions.

  3. Register a tool and connect

    Use new McpServer({ name, version }), then server.registerTool(name, config, handler), then await server.connect(new StdioServerTransport()). See the code block below.

  4. Build and run

    Compile with tsc, then run node build/index.js. The only line that prints uses console.error, so the startup diagnostic lands on stderr.

server.ts — minimal stdio server (SDK 1.29.0)
typescript
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.

Resources and a prompt (TypeScript)
typescript
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)

  1. Scaffold with uv

    Run uv init weather && cd weather, then uv venv && source .venv/bin/activate.

  2. Add the SDK with the CLI extra

    Run uv add "mcp[cli]" httpx. The [cli] extra ships the mcp command.

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

  4. Run it

    Call mcp.run(transport="stdio") (switch to "streamable-http" for remote), then uv run weather.py.

weather.py — FastMCP server (mcp 1.27.2)
python
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()
CommandWhat it does
uv run mcp dev weather.pyRuns the server and launches the MCP Inspector against it in one step (add deps with --with pandas).
uv run mcp install weather.pyInstalls the server into Claude Desktop (supports a custom name + env vars).
uv run mcp run weather.pyRuns the server directly (FastMCP servers only, not the low-level Server).
The mcp CLI (installed by the mcp[cli] extra). Run each via uv run.

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.

Serversrc/server.ts
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);});
Setup & runnpm + tsx
npm init -ynpm install @modelcontextprotocol/sdk zodnpm install -D typescript tsx @types/nodenpx tsx src/server.ts

TypeScript MCP server exposing Tools over stdio.

Pick a language, the primitives you need, and a transport — and copy a runnable starting point.

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.

inspect the server
… scroll to run this session
Launch the Inspector against a built stdio server, then drive it headless from the CLI.

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

  1. Claude Code

    Use claude mcp add with a --transport flag and a scope (local default, project shared via .mcp.json, or user). 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; JSON type accepts streamable-http as an alias for http). Manage with claude mcp list / get / remove, and check live status inside a session with /mcp.

  2. 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=VALUE before --). Remote: codex mcp add weather --url https://example.com/mcp writes a streamable_http transport; for OAuth servers run codex mcp login <name>.

  3. Gemini CLI

    Edit the mcpServers object in ~/.gemini/settings.json (global) or .gemini/settings.json (project). Stdio entries take command, args, env, cwd, timeout, and trust. For HTTP use "httpUrl": "http://localhost:3000/mcp" (SSE uses "url"). Inspect connected servers with /mcp inside a session.

~/.gemini/settings.json — registering the stdio server
json
{
  "mcpServers": {
    "weather": {
      "command": "node",
      "args": ["build/index.js"],
      "env": { "API_KEY": "$MY_API_TOKEN" },
      "cwd": "./server-dir",
      "timeout": 30000,
      "trust": false
    }
  }
}
HostAdd a stdio serverConfig lives in
Claude Codeclaude mcp add --transport stdio weather -- node build/index.js~/.claude.json or .mcp.json (by scope)
Codex CLIcodex mcp add weather -- node build/index.js~/.codex/config.toml
Gemini CLIAdd to the mcpServers object (see JSON above)~/.gemini/settings.json
Stdio registration across the three CLIs. All three also support remote servers (URL form noted in the steps).

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.