Running your server
mcp.run() starts the server.
The only decision you make is the transport: how the bytes between your server and its client actually move.
Pick a transport
| Transport | What it is | When |
|---|---|---|
stdio |
The host launches your file as a subprocess and speaks over its stdin and stdout. | Local servers. The default. |
streamable-http |
A real HTTP server listening on a port. | Anything you deploy. |
sse |
The older HTTP transport. | You don't. |
Warning
SSE was superseded by Streamable HTTP in the 2025-03-26 protocol revision.
mcp.run(transport="sse") still works, with its own sse_path= and message_path=
options, but it exists for clients that haven't moved. Don't build anything new on it.
mcp.run()
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
@mcp.tool()
def search_books(query: str) -> str:
"""Search the catalog by title or author."""
return f"Found 3 books matching {query!r}."
if __name__ == "__main__":
mcp.run()
run()is synchronous. It blocks for the life of the server.- With no argument, the transport is
stdio. - It sits under
if __name__ == "__main__":because everything that loads your server (mcp dev,mcp run,mcp install, your tests) imports this file. The guard keeps an import from turning into a running server.
stdio
There is nothing to configure. The host starts your file as a child process, writes requests to its stdin, and reads responses from its stdout.
Run it yourself and you see the consequence:
python server.py
Nothing prints, and it doesn't return. It is waiting on stdin for a host to speak first.
That also means stdout is the wire. A stray print() corrupts the stream; the logging module writes to stderr and is the right tool. That story is in Logging.
Try it
uv run mcp dev server.py
The Inspector does exactly what a real host does: it launches server.py as a subprocess and connects to it over stdio.
You never gave it a port. There isn't one.
Streamable HTTP
To put the same server on a port instead, name the transport (and its options) in run():
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
@mcp.tool()
def search_books(query: str) -> str:
"""Search the catalog by title or author."""
return f"Found 3 books matching {query!r}."
if __name__ == "__main__":
mcp.run(transport="streamable-http", port=3001)
That one line builds a Starlette app and serves it with uvicorn. Clients connect to http://127.0.0.1:3001/mcp.
Each transport has its own keyword arguments, all on run():
host/port: where to listen. Defaults127.0.0.1and8000.streamable_http_path: where the MCP endpoint lives. Default/mcp.json_response=True: answer with plain JSON instead of an SSE stream.stateless_http=True: a fresh transport per request, no session tracking.event_store,retry_interval,transport_security: resumability and DNS-rebinding protection. They can wait, until you deploy somewhere other than localhost; ASGI coverstransport_security.
Warning
Transport options go to run(), not to MCPServer(...). The constructor describes what
your server is: name, version, instructions. run() describes how it is served. Get it
backwards and Python answers before MCP is even involved:
TypeError: MCPServer.__init__() got an unexpected keyword argument 'port'
run() is the short road. The moment you need more (your server mounted inside an existing app, two servers in one process, CORS for browser clients), you build the ASGI app yourself and hand it to any ASGI host. That is ASGI.
Server settings
A couple of things about running are not about the transport. They are constructor arguments:
from mcp.server import MCPServer
mcp = MCPServer("Bookshop", log_level="DEBUG")
@mcp.tool()
def search_books(query: str) -> str:
"""Search the catalog by title or author."""
return f"Found 3 books matching {query!r}."
if __name__ == "__main__":
mcp.run()
log_level: handed tologging.basicConfig()the momentMCPServer(...)is constructed. That configures the root logger, so it sets the level for your own loggers too, not just the SDK's. Default"INFO".debug: forwarded to the Starlette app that the HTTP transports build. DefaultFalse.
Both land on mcp.settings, which you can read back at runtime.
The mcp command
The [cli] extra installs a small command-line tool around all of this.
mcp dev runs your server under the MCP Inspector:
uv run mcp dev server.py
uv run mcp dev server.py --with pandas --with numpy
uv run mcp dev server.py --with-editable .
--with adds packages to the environment it builds; --with-editable installs your own package into it. It needs npx on your PATH: the Inspector is a Node.js app.
mcp run imports the file, finds the server object (a module-level mcp, server, or app), and calls run() on it:
uv run mcp run server.py
uv run mcp run server.py:bookshop
The : suffix names the object when it isn't called mcp, server, or app.
Your if __name__ == "__main__": block never executes here: mcp run calls run() itself, and the only option it forwards is --transport.
mcp install registers the server with Claude Desktop, so the app launches it for you:
uv run mcp install server.py --name "Bookshop"
uv run mcp install server.py -v API_KEY=abc123 -f .env
-v KEY=VALUE and -f .env record environment variables in that entry. Claude Desktop starts your server in its own process. Your shell's environment is not there.
mcp version prints the installed SDK version.
Tip
mcp dev and mcp run only understand MCPServer. If you build with the low-level Server,
you run it yourself. See The low-level Server.
Recap
- A transport is how bytes reach your server:
stdiofor a local subprocess,streamable-httpfor a port. SSE is superseded. mcp.run()picks the transport. With no argument it isstdio, and it blocks.- Every transport option (
host,port,streamable_http_path, ...) is an argument torun(), never toMCPServer(...). - Keep
run()underif __name__ == "__main__":. Everything that loads your server imports the file first. log_level=anddebug=are constructor arguments; they land onmcp.settings.mcp devfor the Inspector,mcp runto execute a file,mcp installfor Claude Desktop,mcp versionfor the version.- The transport never changes what your server is: all three files on this page expose the identical tool.
When run() itself is the limit (your server inside an app that already exists), the next step is ASGI.