Most MCP server tutorials stop at a local weather tool. That is the official build a server path, and it is the right first hour. It is also why so many people then paste a command into claude_desktop_config.json and wonder why Claude.ai cannot see the server.

This MCP server tutorial is the second hour. You will keep one Python file, run it as stdio, inspect it, then change a single run() argument so the same tools speak Streamable HTTP. After that you will point a client at http://127.0.0.1:3001/mcp, and you will see what still has to change before that URL can live on the public internet.

If you want the SDK tour and the OAuth design notes, use how to build an MCP server. If you want the category explainer, use remote MCP servers. This page is the walk.

Checked against spec 2026-07-28 and the official Python SDK v2 on 11 September 2026.

What you will have at the end

Teal Ethernet cable coiled on a laptop keyboard
Photo: David Davies / Flickr (CC BY-SA 2.0).

A tiny MCP server example with one tool, now, that returns UTC time. You will run it two ways:

  1. Local stdio. The host launches server.py as a subprocess and talks over stdin and stdout. No port. This is how Claude Desktop local servers work.
  2. Local Streamable HTTP. The same process listens on 127.0.0.1:3001/mcp. Claude Code, Cursor, VS Code, and the Inspector can POST to it. Claude custom connectors cannot, because they originate in Anthropic's cloud, not on your laptop.

You need Python 3.10+, uv or pip, and Node 22.19+ on PATH so the Inspector can start. The [cli] extra on the Python SDK is worth installing; it gives you mcp dev and mcp run.

uv add "mcp[cli]"
# or: pip install "mcp[cli]"

Write the server once

Save this as server.py. It is the official v2 shape: MCPServer, a decorated tool, and run() under a main guard. The SDK docs use a bookshop search. A clock is smaller and still proves the transport flip.

from datetime import datetime, timezone

from mcp.server import MCPServer

mcp = MCPServer("clock")

@mcp.tool()
def now() -> str:
    """Current UTC time as ISO 8601."""
    return datetime.now(timezone.utc).isoformat()

if __name__ == "__main__":
    mcp.run()

That is a complete stdio MCP server. You did not write JSON Schema, JSON-RPC, or a handshake. Type hints become the tool input schema. The docstring becomes the tool description the model sees.

Keep run() inside if __name__ == "__main__":. mcp dev, mcp run, and your tests import this file. Without the guard, an import starts a server.

Do not put port= on MCPServer(...). Transport options belong on run(). Get it backwards and Python raises TypeError before MCP is involved. The run docs are explicit about that.

Run it as stdio

With no argument, mcp.run() uses stdio. The host starts your file as a child process, writes newline-delimited JSON-RPC to stdin, and reads it from stdout. There is no URL and no port.

If you run python server.py yourself, nothing prints and it does not return. It is waiting for a host. That is correct.

Stdout is the wire. The stdio spec says the server MUST NOT write anything to stdout that is not a valid MCP message. Logs go to stderr. While serving, the Python SDK diverts flushed stdout to stderr so a stray print() is less likely to corrupt the stream. Output that lands on stdout before serving starts still breaks the connection. Use logging.

To cancel an in-flight stdio request, the client sends notifications/cancelled. There is no per-request stream to close.

Inspect the stdio process

The official MCP Inspector is @modelcontextprotocol/inspector. The Python SDK wraps it:

uv run mcp dev server.py

That launches server.py as a subprocess over stdio and prints a browser URL with a one-time session token. In Tools, call now. You should get an ISO timestamp. You never gave it a port. There isn't one.

If you prefer the Inspector binary directly:

npx @modelcontextprotocol/inspector uv run python server.py

Node 22.19.0 or newer. mcp dev needs npx on PATH for the same reason.

The Inspector also has a CLI mode, which is useful when you do not want a browser:

npx @modelcontextprotocol/inspector --cli uv run python server.py --method tools/list

tools/list and tools/call are the JSON-RPC methods (not the tool names). Wire shape, with DialMCP as the phone-call example: MCP server commands and tool calls.

Point Claude Desktop at it

Claude Desktop still launches local servers from claude_desktop_config.json (macOS: ~/Library/Application Support/Claude/claude_desktop_config.json). A stdio entry is a command, not a URL:

{
  "mcpServers": {
    "clock": {
      "command": "uv",
      "args": ["run", "python", "/absolute/path/to/server.py"]
    }
  }
}

Use an absolute path. Restart Desktop after you save. mcp install server.py --name clock writes a Desktop entry for you; it does not know Claude Code, Cursor, or VS Code.

This config will never make the clock appear in claude.ai or Cowork. Those clients do not spawn your subprocess.

Flip the same file to Streamable HTTP

Change the last line.

if __name__ == "__main__":
    mcp.run(transport="streamable-http", port=3001)

The tools did not change. The SDK now builds a Starlette app and serves it with uvicorn. Clients connect to http://127.0.0.1:3001/mcp. Defaults, if you omit them, are host 127.0.0.1, port 8000, path /mcp. Binding localhost is the spec recommendation for anything that is not intentionally public.

Run it:

uv run python server.py

This time the process is an HTTP server. It will sit on 3001 until you stop it.

Do not use transport="sse". SSE was superseded by Streamable HTTP in protocol version 2025-03-26. The Python SDK still accepts it for old clients. Do not build anything new on it.

What the HTTP wire actually looks like

On spec 2026-07-28, Streamable HTTP is one MCP endpoint that accepts POST. Each client message is its own POST. The body is one JSON-RPC request. The server answers with either application/json or a request-scoped text/event-stream. Clients must support both.

Required headers include MCP-Protocol-Version and Mcp-Method. For tools/call, also Mcp-Name. Header values must match the body. A mismatch is HTTP 400 plus JSON-RPC error -32020 (HeaderMismatch).

There is no initialize handshake on this revision, and no Mcp-Session-Id. Every request carries protocol version and client capabilities in _meta. Cancellation is closing the response stream, not notifications/cancelled.

You do not have to write those headers by hand. A current SDK client will. You do have to stop copying 2025 samples that open a GET SSE stream or mint session IDs.

The spec also says: validate Origin. If the header is present and invalid, respond 403. That matters the moment the port is reachable from a browser.

Inspect the HTTP server

Leave server.py running, then point the Inspector at the URL:

npx @modelcontextprotocol/inspector --server-url http://127.0.0.1:3001/mcp --transport http

Call now again. Same tool, different transport. If this works and stdio also worked, the server is not your problem when a desktop host later fails. The host config is.

Point Claude Code at localhost

Claude Code prefers HTTP for anything that already has a URL:

claude mcp add --transport http clock http://127.0.0.1:3001/mcp

A project .mcp.json needs "type": "http". A url with no type is treated as stdio and skipped. Details and the other clients live on the configuration examples page. Cursor infers HTTP from url. VS Code wants a servers key and "type": "http" in .vscode/mcp.json, not settings.json.

Claude custom connectors are a different story. When you paste a URL under Settings → Connectors, Claude connects from Anthropic's cloud. 127.0.0.1 is your loopback, not theirs. A working local HTTP server will fail as a custom connector for that reason alone.

What still has to change before you host it

Local Streamable HTTP is not a remote MCP server in the useful sense. It is the remote transport on loopback. Moving it onto a public URL is a short list, and skipping any item is how people ship a debug port.

Give it a real hostname and TLS. Clients that speak remote MCP expect https://…/mcp. Claude connectors will not dial your laptop. The container path (stdio image vs Streamable HTTP image, Host allowlist, the 421) is deploy an MCP server with Docker.

Keep Origin checks and a tight bind until you mean it. The spec's localhost default exists because a Streamable HTTP server on 0.0.0.0 is a DNS-rebinding target. The Python SDK's transport_security options are documented under Deploy & scale; turn them on before the process leaves your machine.

Add auth. Authorization is optional in the MCP spec and expected in production the moment the URL is public and the tools touch user data. HTTP implementations should follow the OAuth 2.1 framework MCP added in 2025-03-26. Stdio servers should not run HTTP OAuth; they take secrets from the environment. If you are not ready to be an OAuth resource server, do not put the process on the internet.

Assume any instance can take any request. 2026-07-28 is stateless. Cross-call state is a handle you mint and pass back as a normal tool argument, not a session cookie. That is why a phone-call server returns a call ID and makes you poll it.

Do not confuse a stdio bridge with a second product. Clients that can only spawn local processes can run a small proxy that speaks stdio to the host and Streamable HTTP to the hosted server. DialMCP's install path has one (npx dialmcp-connector). The calling service still lives at https://mcp.dialmcp.com/mcp. The bridge is a shim.

When the capability is local (files, a Docker daemon, a browser on this machine), stop at stdio. Putting that on a public URL is the next incident report, not a tutorial win.

When to stop building and connect someone else's server

A clock is a good MCP server example because you can see the transport. It is a bad product. If the job is "my agent should place a real phone call," you do not need another server.py. You need a hosted remote MCP server that already owns PSTN, disclosure, and opt-out.

DialMCP is that server: Streamable HTTP at https://mcp.dialmcp.com/mcp, OAuth 2.1, SMS-verified caller ID on your own US or Canadian number. Paste the URL into Claude Code, Cursor, VS Code, or Claude connectors. If the client only speaks stdio, use the npx bridge. Setup snippets are on client setup.

The same rule applies to GitHub, Sentry, Notion, and Context7. Build an MCP server when the tool is yours. Connect one when the tool is a service.

Need a phone-call MCP server instead of another clock? DialMCP is already hosted at https://mcp.dialmcp.com/mcp. Connect it and skip the deploy step.

Install DialMCP

FAQ

Is this different from "how to build an MCP server"?

Yes. That page is the SDK and protocol tour, including TypeScript. This MCP server tutorial is the stdio-to-remote walk with one file.

Can I start on Streamable HTTP and skip stdio?

Yes, if every client you care about already speaks HTTP. Start on stdio if you are targeting Claude Desktop's local config, or if the tool needs the machine (files, browser, Docker).

Why does python server.py hang?

On stdio, hanging is the server waiting on stdin. On Streamable HTTP, the process should log that it is listening. If you expected HTTP and it hangs with no port, you are still on the default transport.

Why does Claude.ai not see my server?

Custom connectors originate in Anthropic's cloud. Localhost and VPN-only URLs will not connect. Desktop's claude_desktop_config.json is a separate, local-only mechanism.

Should I implement SSE?

No. Streamable HTTP may still return a request-scoped SSE stream as a response body. That is not the deprecated HTTP+SSE transport from 2024-11-05.

Where do I put the JSON?

It depends on the host. Claude Code wants .mcp.json with "type": "http". VS Code wants .vscode/mcp.json with a servers key. Cursor wants .cursor/mcp.json. Do not copy blocks across those files. The configuration page is the cheat sheet.

Further reading