hubagenticai

Tutorials Beginner · 4/8

How to build your first MCP server, step by step (Python and TypeScript)

Build a working Model Context Protocol server with tools and resources, connect it to a real client, and understand what actually happens on the wire.

updated 2026-10-08 ⏱ 45–60 min

The Model Context Protocol (MCP) is the USB port of agentic AI: one connector standard that lets any model plug into your tools, files, and data. This tutorial builds the smallest MCP server worth building — one tool, one resource — and, more importantly, shows you how to verify it works before you point a model at it.

Step 1 — Understand the three primitives

Before writing code, know what you’re exposing. An MCP server offers three things to a client:

PrimitiveWho triggers itUse it for
ToolThe model decidesActions and computations (“look up an order”)
ResourceThe application attaches itRead-only context (“the config file”)
PromptThe user picks itReusable interaction templates

Most first servers only need tools. We’ll add a resource so you see both.

One thing changed in 2026. The current MCP spec (revision 2026-07-28) is stateless: there is no opening handshake any more, and every request carries its own protocol version and client details. An open connection is not a session, so a server must not remember things between calls. For a first server this changes nothing you write. The current SDKs handle the new protocol for you, and they still answer older clients that use the 2025-era handshake. The practical rule: if a tool needs something from an earlier call, pass it in as an argument. Don’t keep it in a global variable.

Step 2 — Python track: scaffold with the official SDK

mkdir hello-mcp && cd hello-mcp
python3 -m venv .venv && source .venv/bin/activate
pip install "mcp[cli]"

This installs the official Python SDK, version 2.x. Create server.py:

from mcp.server import MCPServer

mcp = MCPServer("hello-mcp")

@mcp.tool()
def order_status(order_id: str) -> str:
    """Look up the shipping status of an order by its ID."""
    # Stand-in for a real database call.
    fake_db = {"1001": "shipped", "1002": "processing"}
    return fake_db.get(order_id, "not found")

@mcp.resource("config://policies/returns")
def returns_policy() -> str:
    """The current returns policy document."""
    return "Returns accepted within 30 days with receipt."

if __name__ == "__main__":
    mcp.run()  # stdio transport by default

That’s a complete server. The docstrings matter: they become the tool descriptions the model reads when deciding what to call. The type hints matter too: order_id: str becomes the tool’s input schema.

Following an older tutorial that uses FastMCP? In SDK 1.x the class was from mcp.server.fastmcp import FastMCP. SDK 2.x renamed it to MCPServer, and the old import now fails with ModuleNotFoundError. The decorators did not change, so most 1.x examples work once you fix the import. If you need 1.x on purpose (some agent frameworks still pin it), install "mcp[cli]<2" and keep the old import. See migrating an MCP server to the 2026 spec.

Step 3 — Verify with the MCP Inspector

Never debug a server through a model — use the Inspector first:

mcp dev server.py

This opens a browser UI (the Inspector needs Node.js 22.19 or newer) where you can list tools, call order_status with {"order_id": "1001"}, read the config://policies/returns resource, and see the raw JSON-RPC request and response. If it works here, it will work in any client.

The Inspector’s Protocol Era setting starts on legacy, which means the old 2025 handshake. Your server speaks both eras. Switch the setting to auto if you want to see the 2026-07-28 traffic.

Step 4 — TypeScript track: the same server in Node

mkdir hello-mcp-ts && cd hello-mcp-ts
npm init -y && npm install @modelcontextprotocol/server zod

The TypeScript SDK v2 is split into packages, and @modelcontextprotocol/server is the one for servers. (The older single package, @modelcontextprotocol/sdk, is v1. It still installs and works, but it is the legacy line.)

Create server.mjs:

import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { z } from 'zod';

function buildServer() {
  const server = new McpServer({ name: 'hello-mcp', version: '1.0.0' });

  server.registerTool(
    'order_status',
    {
      description: 'Look up the shipping status of an order by its ID.',
      inputSchema: z.object({ order_id: z.string() }),
    },
    async ({ order_id }) => {
      const fakeDb = { 1001: 'shipped', 1002: 'processing' };
      return { content: [{ type: 'text', text: fakeDb[order_id] ?? 'not found' }] };
    }
  );

  server.registerResource(
    'returns-policy',
    'config://policies/returns',
    { description: 'The current returns policy document.', mimeType: 'text/plain' },
    async (uri) => ({
      contents: [{ uri: uri.href, text: 'Returns accepted within 30 days with receipt.' }],
    })
  );

  return server;
}

// serveStdio answers both the 2026-07-28 protocol and the older
// initialize handshake, building one server per connection.
serveStdio(buildServer);

Two differences from the Python version. First, you register tools and resources with registerTool and registerResource, each taking a config object. Second, you hand serveStdio a function that builds the server rather than a server object. That factory is what lets one file serve both protocol eras. If you construct a server and call server.connect(...) yourself, it keeps speaking the 2025-era protocol.

Verify it the same way: npx @modelcontextprotocol/inspector node server.mjs.

Step 5 — Connect it to a real client

For Claude Code, register the server from your project directory:

claude mcp add hello-mcp -- /full/path/to/hello-mcp/.venv/bin/python /full/path/to/hello-mcp/server.py
# TypeScript track: claude mcp add hello-mcp-ts -- node /full/path/to/hello-mcp-ts/server.mjs

Use the venv’s own python, not a bare python. The client starts your server as a subprocess and won’t see your activated virtualenv.

Then ask: “What’s the status of order 1001?” — the model will discover order_status, call it, and answer “shipped.” You built the bridge; the model found it on its own. That discovery step is the entire point of MCP.

Troubleshooting

Client shows the server as “failed” immediately

Nine times out of ten the command path is wrong. MCP clients launch your server as a subprocess; use absolute paths (/home/you/hello-mcp/.venv/bin/python) rather than relying on the client inheriting your shell’s virtualenv.

ModuleNotFoundError: No module named 'mcp.server.fastmcp'

You’re running 1.x code on SDK 2.x, which pip install "mcp[cli]" now installs. Change the import to from mcp.server import MCPServer and FastMCP(...) to MCPServer(...). To stay on 1.x, run pip install "mcp[cli]<2" instead.

TypeScript: every request fails with -32603 Internal server error

serveStdio runs your factory when the first request arrives, so an exception inside buildServer() turns into a generic Internal server error and nothing is printed. To see the real error, pass a handler: serveStdio(buildServer, { onerror: (e) => console.error(e) }). A common cause is v1 code: TypeError: server.tool is not a function. server.tool(...) and server.resource(...) were deprecated in @modelcontextprotocol/sdk 1.x and are gone from @modelcontextprotocol/server 2.x. Use registerTool(name, config, handler) and registerResource(name, uri, config, handler) as shown in Step 4.

Input should be a valid string / expected string, received number

The tool expects order_id as a string, and the client sent the number 1001. The Inspector CLI does this with --tool-arg order_id=1001; quote the value as --tool-arg 'order_id="1001"'. In the web UI, enter "1001" with quotes. The rejection is the schema working: the type hint is the contract.

mcp: command not found after pip install

You installed mcp without the CLI extra, or your venv isn’t activated. Run pip install "mcp[cli]" inside the activated venv.

Tools appear but every call returns an error

Print to stderr, never stdout. On stdio transport, stdout carries the JSON-RPC protocol — a stray print() corrupts the stream. Use logging (Python) or console.error (Node) for debugging output.

newsletter

One practical agentic-AI guide in your inbox. No news, no hype.

Tutorials and decision frameworks as they ship. Unsubscribe anytime.

Quick Jump: