Skip to main content
MCP (Model Context Protocol) · lesson 10 of 13
MCP (Model Context Protocol) advanced Lesson 10 of 13

Streamable HTTP, Testing and the MCP Inspector

Serve MCP over Streamable HTTP, call it from Client and curl with the required headers, test it in memory with pytest, and drive it with the Inspector.

By SkillByExample · Published

mcp model-context-protocol http testing pytest inspector

Beyond stdio

Stdio suits a server that runs on the user’s machine, launched by one host. A server that many users share, or that runs on another machine, speaks Streamable HTTP: every JSON-RPC message is an HTTP POST to one URL. The server code doesn’t change, only run() does. This lesson also tests the server properly, and checks it with a client you didn’t write, the official MCP Inspector.

The Same Server Over HTTP

This is lesson 1’s calc server; only the last line changes, from stdio to an HTTP endpoint on port 8000.

# http_server.py - the calc server over Streamable HTTP on localhost
from mcp.server import MCPServer

mcp = MCPServer("calc")


@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two integers."""
    return a + b


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

The client passes a URL instead of a command.

# http_client.py - the same scripted client, pointed at a URL
import asyncio

from mcp import Client


async def main():
    async with Client("http://127.0.0.1:8000/mcp") as client:
        print("protocol:", client.protocol_version)
        tools = await client.list_tools()
        print("tools:", [t.name for t in tools.tools])
        result = await client.call_tool("add", {"a": 2, "b": 3})
        print("structured:", result.structured_content)


asyncio.run(main())

Start the server in the background, run the client, and count the requests the server logged:

$ python http_server.py > server.log 2>&1 &
$ sleep 3
$ python http_client.py
protocol: 2026-07-28
tools: ['add']
structured: {'result': 5}
$ grep -c '"POST /mcp HTTP/1.1" 200' server.log
3
$ kill $!

The output matches the stdio client’s from the first lesson. The server ran on uvicorn and logged three POSTs: server/discover when the client connected, then tools/list and tools/call. Each was a separate HTTP request with its own envelope, which is what the stateless 2026-07-28 design allows. The server kept no session, so each POST could have gone to a different instance behind a load balancer.

host="127.0.0.1" matters. It accepts connections only from this machine. Binding to 0.0.0.0 exposes the server to your network, and an exposed server needs the authorization the security lesson points to.

The Request as curl Sees It

HTTP adds headers on top of the wire protocol lesson’s JSON. call.json holds a tools/call request:

{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
 "params": {"name": "add", "arguments": {"a": 2, "b": 3},
            "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
                      "io.modelcontextprotocol/clientCapabilities": {}}}}

With the server running again, send it three times, changing one header each time:

$ curl -s http://127.0.0.1:8000/mcp -d @call.json \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/call' -H 'Mcp-Name: add'
{"jsonrpc":"2.0","id":1,"result":{"content":[{"text":"5","type":"text"}],"isError":false,"resultType":"complete","structuredContent":{"result":5},"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"calc","version":""}}}}

$ curl -s http://127.0.0.1:8000/mcp -d @call.json \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/list'
{"jsonrpc":"2.0","id":1,"error":{"code":-32020,"message":"mcp-method header does not match the request body's method"}}

$ curl -s http://127.0.0.1:8000/mcp -d @call.json -w ' (HTTP %{http_code})' \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/call' -H 'Mcp-Name: add' \
  -H 'Origin: http://evil.example'
Invalid Origin header (HTTP 403)

The first response is the stdio result, byte for byte, in an HTTP body. The headers repeat what the body already says:

  • MCP-Protocol-Version gives the protocol version.
  • Mcp-Method names the JSON-RPC method.
  • Mcp-Name names the tool.

A gateway or firewall can route or block calls by reading the headers, without parsing JSON. Since something may act on the headers alone, the server insists they agree with the body. The second request claimed to be tools/list while carrying a tools/call, and was rejected with -32020. Leaving out Mcp-Name on this call gets the same error, naming the mcp-name header instead.

The third request added an Origin header, which every browser sends with a cross-site request. The server refused it with HTTP 403 and a plain-text message, not JSON-RPC. That blocks DNS rebinding, where a web page you visit tries to reach a server on your own localhost. curl and the SDK client send no Origin, so they pass.

Leave out MCP-Protocol-Version and the server treats the POST as the older, session-based protocol. It then answers Bad Request: Missing session ID, because that protocol starts with initialize.

Testing in Memory

Client(mcp) from the first lesson is also the way to test. The SDK installs anyio, whose pytest plugin runs async tests. server.py is the stdio calc server from the first lesson.

Install pytest first with pip install pytest.

# test_calc.py - test the server in memory with pytest; no subprocess, no port
import pytest

from mcp import Client

from server import mcp

pytestmark = pytest.mark.anyio


@pytest.fixture
def anyio_backend():
    return "asyncio"


async def test_add_returns_structured_result():
    async with Client(mcp) as client:
        result = await client.call_tool("add", {"a": 2, "b": 3})
    assert result.is_error is False
    assert result.structured_content == {"result": 5}


async def test_add_rejects_a_string():
    async with Client(mcp) as client:
        result = await client.call_tool("add", {"a": "two", "b": 3})
    assert result.is_error is True
    assert "valid integer" in result.content[0].text


async def test_tool_list_is_stable():
    async with Client(mcp) as client:
        tools = (await client.list_tools()).tools
    assert [t.name for t in tools] == ["add"]
    assert tools[0].input_schema["required"] == ["a", "b"]
$ python -m pytest -q test_calc.py
...                                                                      [100%]
3 passed in 1.01s

Your timing will differ. All three tests went through the same discover, list and call requests as the HTTP client, minus the transport, so they test your tools, schemas and error handling directly. pytestmark marks every test in the file for anyio, and the anyio_backend fixture says to run them on asyncio. The third test pins down the tool list and its schema. A renamed argument changes what every host’s model sees, so it’s worth a failing test.

A Client You Didn’t Write: the Inspector

The MCP Inspector is the protocol’s official debugging tool. It needs Node.js: this run used Node 24, because the npm 10.8 that ships with Node 20 failed to install it with Cannot read properties of null (reading 'edgesOut'). --cli makes one request and prints the result.

$ npx -y @modelcontextprotocol/inspector@2.11.0 --cli python server.py --method tools/call --tool-name add --tool-arg a=2 --tool-arg b=3 2>/dev/null
{
  "content": [
    {
      "type": "text",
      "text": "5"
    }
  ],
  "structuredContent": {
    "result": 5
  },
  "isError": false
}

The Inspector launched server.py over stdio, just as Client did, and called the tool. That’s an independent implementation agreeing with yours. The result has no resultType, because Inspector 2.11.0 connected with the older initialize handshake. MCPServer serves both kinds of client, as the wire protocol lesson showed. The 2>/dev/null hides npm’s notices. Run it without --cli to open the Inspector’s web UI, where you can browse tools, resources and prompts by hand.

Practice

  1. Run http_server.py on port 9000 and point the client at it.
  2. Add a test that calls add with {"a": 1} and asserts the result is an error mentioning the missing field b.
  3. Use the Inspector CLI to list the server’s tools instead of calling one.
  4. Send call.json with curl but without the MCP-Protocol-Version header. What comes back, and why?
Solution 1
mcp.run(transport="streamable-http", host="127.0.0.1", port=9000)
async with Client("http://127.0.0.1:9000/mcp") as client:

The path stays /mcp; only the port changes. To change the path too, pass streamable_http_path="/api/mcp" to run().

Solution 2
async def test_add_needs_b():
    async with Client(mcp) as client:
        result = await client.call_tool("add", {"a": 1})
    assert result.is_error is True
    assert "b" in result.content[0].text
    assert "Field required" in result.content[0].text

It passes: the validation message names the field and says Field required, as the wire protocol lesson showed on the raw response.

Solution 3
npx -y @modelcontextprotocol/inspector@2.11.0 --cli python server.py --method tools/list

It prints {"tools": [{"name": "add", "description": "Add two integers.", "inputSchema": ...}]}, indented. The JSON is the same tools/list result your client parses.

Solution 4

{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Bad Request: Missing session ID"}} with HTTP 400. Without the version header the server treats the POST as the pre-2026 protocol, where a client first sends initialize and then quotes the session id the server issued. The modern protocol needs no session, which is why the header matters.

Frequently Asked Questions

How do I run an MCP server over HTTP?
With the Python SDK, call mcp.run(transport="streamable-http", host="127.0.0.1", port=8000). The server listens at http://127.0.0.1:8000/mcp, and a client connects by passing that URL to Client. Every JSON-RPC request is an HTTP POST to that one endpoint.
What HTTP headers does an MCP request need?
A 2026-07-28 request is a POST with Content-Type: application/json, an Accept header listing application/json and text/event-stream, MCP-Protocol-Version, and Mcp-Method naming the JSON-RPC method. Methods that target a named tool, resource or prompt also need Mcp-Name. The Python SDK server rejects a header that disagrees with the body with error -32020.
How do I unit test an MCP server?
Pass the server object to the SDK's Client, as in Client(mcp), and call it from an async pytest test. The client then talks to the server in the same process, with no subprocess and no network port, so tests are fast and run anywhere. The anyio pytest plugin, installed with the SDK, runs async test functions.
What is the MCP Inspector?
The MCP Inspector is the official tool for poking at a server by hand. Started with npx @modelcontextprotocol/inspector, it opens a web UI; with --cli it runs one request, such as --method tools/call, and prints the JSON result. It's useful to check that a server works in a client you didn't write.
Why does my MCP HTTP server return 403 Invalid Origin header?
The Python SDK checks the Origin header to block DNS rebinding, where a web page in your browser tries to reach a server on localhost. A request from a browser page on another origin gets 403. Command-line clients send no Origin header, so they pass.