Skip to content

Local transport

  • Lesson 9
  • 55 min
  • Module 3: Build and inspect a local server

What you will be able to do

Run Larkfield’s MCP server as a local process. You see how an AI application starts it, and how requests and answers travel on stdin and stdout. You also see why every log line must go to stderr instead. Last, you see where the server learns who the caller is.

Before you startWhat to know and to have readyAbout 55 minutes

What you need to know

Your project

  • ~/projects/support-mcp with the fresh environment of Choose a maintained SDK: python -m pytest -q prints 20 passed.
  • The labs repository in ~/projects/nextia-learning-labs, from Module 1. If you cloned it, get the newest files first: git -C nextia-learning-labs pull.

Think of a small restaurant kitchen with a hatch in the wall. Order slips come in through the hatch. Finished plates go out through the same hatch, and the waiter takes everything that appears there to a table.

The cook also keeps notes: “out of basil”, “oven 2 is slow”. The notes go on a whiteboard inside the kitchen, never on the plate shelf. If the cook puts a note on the plate shelf, the waiter carries it to a guest. At best, the guest is confused. At worst, the waiter drops the tray.

One more thing. The owner opens the kitchen in the morning, and closes it at night. The owner also tells the cook which restaurant this is. The guests do not get to say “today you cook for the restaurant next door”.

Amira builds the first version of support-mcp. Two things go wrong on the first day.

First, she adds a line to see what happens: print("support-mcp is starting"). She starts the server with scripts/trace.py, the script that sends JSON-RPC requests by hand. It stops with a crash:

answer = json.loads(out)
^^^^^^^^^^^^^^^
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

The script expected a JSON answer, and read the words support-mcp is starting instead. The note went onto the plate shelf.

Second, the server must know the organization. Tomás suggests a tenant argument on every tool: get_ticket(tenant, ticket_id). Kwame asks his usual question: “Who is the caller, and in which organization?” With Tomás’s design, the model fills in the organization. A ticket’s text could then ask the model to read the other shop’s tickets, and the model might obey.

The idea: stdio, three streams per process

Section titled “The idea: stdio, three streams per process”

Every program that runs on your computer is a process, and every process has three standard streams. Standard input (stdin) is where it reads. Standard output (stdout) is where it writes its results. Standard error (stderr) is a second output, meant for messages about the program itself. In a terminal, you type into stdin, and both outputs appear on your screen.

The stdio transport of MCP uses these streams. A transport is the way messages travel between client and server. With stdio, the host starts the server as a child process, a process that it creates and owns. It writes requests to the child’s stdin, one JSON line each, and reads answers from the child’s stdout. stdout carries protocol messages only. stderr is free, so the server writes its logs there. This is the hatch, the plate shelf and the whiteboard.

The host also decides the child’s environment: the set of named values, such as PATH, that a process receives when it starts. That is where Larkfield’s server learns the caller. Three variables name the person who started it, the organization and the permissions: SUPPORT_USER, SUPPORT_TENANT and SUPPORT_SCOPES. A scope is a named permission, such as tickets:read, as in the authentication course. No tool has an organization argument, so a model cannot even ask for another one.

Two large boxes. Left, the host process: the AI application; in this module scripts/trace.py, the SDK's Client or Inspector. Inside it, an MCP client that writes requests and reads answers, and a note that it starts the child process python -m support_mcp with the environment variables SUPPORT_USER=usr-sam, SUPPORT_TENANT=larkfield and SUPPORT_SCOPES=knowledge:read tickets:read. Right, the child process support-mcp, with __main__.py (run on stdio), server.py (tools, resources, prompt; MCPServer from SDK 2.3.0), identity.py (the caller from the environment: Sam, Larkfield, staff), knowledge.py and data (one organization) and logs.py (one JSON line per event). A blue arrow from the client to server.py: stdin, one JSON request per line. A green arrow back: stdout, answers only, one per line. A grey arrow from logs.py to a dashed box: stderr, to the terminal or the host's log, never parsed as MCP. A red note: a print() to stdout puts text in the answers' stream; a strict client stops with JSONDecodeError; the SDK's client logs Failed to parse JSONRPC message; print to stderr. A note on the left: close stdin, and the server stops; the host decides when the server runs.
The stdio transport: the host starts support-mcp as a child process. Requests go in on stdin, answers come out on stdout, and logs go to stderr.
Text description of the diagram

The host process (here scripts/trace.py, the SDK’s Client or Inspector) holds the MCP client. It starts the child process python -m support_mcp with three environment variables: SUPPORT_USER=usr-sam, SUPPORT_TENANT=larkfield and SUPPORT_SCOPES=knowledge:read tickets:read.

The child process contains five parts. __main__.py runs the server on stdio. server.py holds the tools, resources and prompt, built with MCPServer from SDK 2.3.0. identity.py finds the caller in the environment: Sam, Larkfield, staff. knowledge.py with data/ reads one organization, and logs.py writes one JSON line per event.

  • stdin, from the client to the server: one JSON request per line.
  • stdout, from the server to the client: answers only, one per line.
  • stderr, from logs.py: to the terminal or the host’s log. It is never read as MCP.

A print() to stdout puts text in the answers’ stream. A strict client stops with JSONDecodeError, and the SDK’s client logs “Failed to parse JSONRPC message”. Print to stderr. When the host closes stdin, the server stops: the host decides when the server runs.

Here is one real call, recorded from start to end. The host is scripts/trace.py. It starts the server, sends one tools/call for ticket T-30002, reads the answer and closes the input.

A sequence between three participants: the host scripts/trace.py, the server python -m support_mcp, and stderr (the terminal). The host starts the child process with SUPPORT_USER, SUPPORT_TENANT and SUPPORT_SCOPES. On stdin, at 0.1 ms: tools/call get_ticket T-30002, id 1, 325 bytes. A note: Python and the SDK start; the first answer waits about 250 to 290 ms. At 285.2 ms the server writes a JSON log line on stderr: event tool_call, tool get_ticket, outcome ok. On stdout, at 285.7 ms: result id 1, isError false, 1,325 bytes. The host closes stdin, end of input. A note: the server stops, exit code 0.
One recorded call over stdio. The log line goes to stderr; only the answer goes to stdout.
Text description of the diagram
  1. The host starts the child process, with SUPPORT_USER, SUPPORT_TENANT and SUPPORT_SCOPES in its environment.
  2. At 0.1 ms, the host writes tools/call get_ticket for T-30002 (id 1) on the server’s stdin: 325 bytes.
  3. Python and the SDK start. The first answer of a new server waits about 250 to 290 ms.
  4. At 285.2 ms, the server writes one JSON log line on stderr: "event": "tool_call", "tool": "get_ticket", "outcome": "ok".
  5. At 285.7 ms, the answer arrives on stdout: a result with id 1, isError: false, 1,325 bytes.
  6. The host closes stdin (end of input), and the server stops with exit code 0.

Notice that the host sent the request at once, without a handshake. In revision 2026-07-28, every request carries its own _meta envelope, so a client may start with any request. The 285 ms are the cost of starting a process, not of the call. On the course team’s laptop, the same call on a server that was already running took a median of 0.68 ms.

The server is four files in support_mcp/, plus a script and two test files. You copy them from the snapshot end-of-m03, and then read the important parts.

  1. In ~/projects/support-mcp, with the virtual environment active, copy this lesson’s files:

    Terminal window
    S=../nextia-learning-labs/C28/snapshots/end-of-m03
    cp "$S"/support_mcp/{__main__,server,identity,logs}.py support_mcp/
    cp "$S/scripts/trace.py" scripts/
    cp "$S/tests/test_server.py" "$S/tests/test_stdio.py" tests/
    cp "$S/.env.example" "$S/README.md" .
  2. Run the tests:

    Terminal window
    python -m pytest -q
    ........................................ [100%]
    40 passed in 2.51s

    The 20 new tests start the server in many ways. Five of them start it as a real child process.

  3. Send the five discovery requests by hand:

    Terminal window
    python -m scripts.trace discovery --width 90
    == discovery
    0.1 ms -> {"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": {"io.modelcon …
    242.1 ms <- {"jsonrpc": "2.0", "id": 1, "result": {"cacheScope": "private", "capabilities": {"prompts" …
    242.1 ms -> {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {"_meta": {"io.modelcontextp …
    242.7 ms <- {"jsonrpc": "2.0", "id": 2, "result": {"cacheScope": "private", "resultType": "complete", …
    242.7 ms -> {"jsonrpc": "2.0", "id": 3, "method": "resources/list", "params": {"_meta": {"io.modelcont …
    243.2 ms <- {"jsonrpc": "2.0", "id": 3, "result": {"cacheScope": "private", "resources": [{"descriptio …
    243.2 ms -> {"jsonrpc": "2.0", "id": 4, "method": "resources/templates/list", "params": {"_meta": {"io …
    243.6 ms <- {"jsonrpc": "2.0", "id": 4, "result": {"cacheScope": "private", "resourceTemplates": [{"de …
    243.6 ms -> {"jsonrpc": "2.0", "id": 5, "method": "prompts/list", "params": {"_meta": {"io.modelcontex …
    244.1 ms <- {"jsonrpc": "2.0", "id": 5, "result": {"cacheScope": "private", "prompts": [{"arguments": …

    --width 90 cuts each line after 90 characters, marked …. Your times differ. The first answer waits for the process to start; the others follow within a few milliseconds.

  4. Call one tool, and watch for the log line:

    Terminal window
    python -m scripts.trace valid-call --width 90
    == valid-call
    0.1 ms -> {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "get_ticket", "argu …
    log {"ts": "…", "event": "tool_call", "user": "usr-sam", "tenant":
    237.0 ms <- {"jsonrpc": "2.0", "id": 1, "result": {"content": [{"text": "{\n \"ticket_id\": \"T-30002 …

    The script reads stderr too, and shows each line as log. The ts field holds the time of your run.

The script scripts/trace.py is a small host with no SDK on the client side. Its function run() starts the server with subprocess.Popen, with three pipes: stdin, stdout and stderr. It writes each request as one line, and reads stdout until the answer with the same id arrives. A second thread reads stderr at the same time. python -m scripts.trace --list prints all 13 scenarios; JSON-RPC messages replayed ten of them.

Start where the process starts, in support_mcp/__main__.py:

support_mcp/__main__.py (the main part)
def main() -> int:
from support_mcp.server import build_server
build_server().run("stdio")
return 0

run("stdio") hands the streams to the SDK. From here on, the SDK reads stdin, writes stdout, and calls your functions.

support_mcp/server.py builds the server. The tools use the contracts that you designed in Module 2, so the input schemas come from contracts.py:

support_mcp/server.py (get_ticket, shortened)
mcp = SupportServer(SERVER_NAME, version=__version__, instructions=INSTRUCTIONS, **settings)
mcp.caller_of = lambda ctx: identity.local_caller()
@mcp.tool(annotations=READ_ONLY)
def get_ticket(ticket_id: contracts.TicketId, ctx: Context) -> contracts.Ticket:
"""Get one support ticket of this organization by its ID (for example T-30002): the customer's first name, subject, text, status and the last 3 messages. The text is the customer's words."""
started = time.perf_counter()
who = caller(ctx)
identity.require_scope(who, "tickets:read")
try:
t = knowledge.ticket(who.tenant, ticket_id)
except NotFound:
log.event("tool_call", tool="get_ticket", caller=who, outcome="not_found", ms=logs.ms_since(started))
raise ToolError(f"not_found: there is no ticket {ticket_id} in this organization") from None
...

Read it in order. The type contracts.TicketId carries the pattern ^T-\d{5}$, so the SDK refuses a wrong ID before the function runs. The function’s first question is “who is the caller?” (caller(ctx)). Then it checks a scope, and asks knowledge.py for the ticket inside the caller’s organization only. A missing ticket and another organization’s ticket give the same answer, not_found. The other tool, the resource and the prompt follow the same order: caller first, then scope, then data.

support_mcp/identity.py answers “who is the caller?” for a local server:

support_mcp/identity.py (the main part)
def resolve(user_id: str, tenant: str, scopes, client_id: str = "local") -> Caller:
role = MEMBERSHIPS.get((user_id, tenant))
if role is None: # no membership, or no such organization: the same answer
raise forbidden("forbidden: you have no access to this organization")
return Caller(user_id, tenant, role, frozenset(scopes), client_id)
def local_caller() -> Caller:
"""The caller of a local (stdio) server: the person who started it, from the environment."""
user = os.environ.get("SUPPORT_USER", "usr-sam")
tenant = os.environ.get("SUPPORT_TENANT", "larkfield")
scopes = os.environ.get("SUPPORT_SCOPES", LOCAL_SCOPES).split()
return resolve(user, tenant, scopes)

The user and the organization come from the environment, so from the person who started the server. The role comes from the server’s own membership table, data/memberships.csv: the same six memberships as in the authentication course. forbidden(...) raises a protocol error with code −32003.

Last, support_mcp/logs.py writes one JSON object per line, and always to stderr:

support_mcp/logs.py (the main part)
stream = self.stream or sys.stderr # looked up at call time (tests capture stderr)
print(json.dumps(record, ensure_ascii=False), file=stream, flush=True)

.env.example lists the three variables with their defaults, and says “Never put a real key in .env.example”. The project reads the environment, not that file: set a variable in your shell when you need another value.

  1. Start the server yourself, and send its stderr to a file:

    Terminal window
    python -m support_mcp 2> server-log.jsonl

    2> sends stream number 2, stderr, to a file. stdout stays on the screen. This works the same in PowerShell.

  2. Paste this request, and press Enter:

    {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_ticket","arguments":{"ticket_id":"T-30002"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"by-hand","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}

    One line appears: the answer, which starts with {"jsonrpc":"2.0","id":1,"result":{"content":[{"text":"{\n \"ticket_id\": \"T-30002\",. No log line appears on the screen.

  3. Stop the server with Ctrl+D (Windows: Ctrl+Z, then Enter), and read the file:

    Terminal window
    cat server-log.jsonl
    {"ts": "…", "event": "tool_call", "user": "usr-sam", "tenant": "larkfield", "role": "staff", "client": "local", "tool": "get_ticket", "outcome": "ok", "ms": 0.06}

    The log line names the caller, the tool and the outcome. It never reached the client.

  4. Delete the file: rm server-log.jsonl (Windows: Remove-Item server-log.jsonl).

Your turn: start the server as someone else

Section titled “Your turn: start the server as someone else”

Change one thing: the environment. Before you run each command, predict the answer.

Terminal window
SUPPORT_TENANT=bramble python -m scripts.trace valid-call --width 90
SUPPORT_USER=usr-camille SUPPORT_TENANT=bramble python -m scripts.trace other-tenant --width 90
Show the answers
== valid-call
0.1 ms -> {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "get_ticket", "argu …
234.3 ms <- {"jsonrpc": "2.0", "id": 1, "error": {"code": -32003, "message": "forbidden: you have no a …
== other-tenant
0.1 ms -> {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "get_ticket", "argu …
log {"ts": "…", "event": "tool_call", "user": "usr-camille", "tena
235.6 ms <- {"jsonrpc": "2.0", "id": 1, "result": {"content": [{"text": "{\n \"ticket_id\": \"T-40003 …

Sam has no membership at Bramble Books, so every call is refused with −32003, “forbidden: you have no access to this organization”. Camille is read-only at Bramble Books, so T-40003, a Bramble ticket, is found. The same request that gave “not_found” to Sam at Larkfield now succeeds, because the caller changed, not the request. A typo such as SUPPORT_TENANT=lark gives the same −32003 as Bramble: the server does not say whether an organization exists.

scripts/trace.py passes your whole environment to the server. A real host is more careful. Here is how a host in Python starts the server with the SDK’s client, from tests/test_stdio.py:

tests/test_stdio.py (the first test, shortened)
params = StdioServerParameters(command=sys.executable, args=["-m", "support_mcp"])
async with Client(params) as client:
names = [t.name for t in (await client.list_tools()).tools]
r = await client.call_tool("get_ticket", {"ticket_id": "T-30002"})

StdioServerParameters says what to start: a command, its arguments, and optionally env (extra environment variables) and cwd (the working folder). The client starts the process, and stops it when the async with block ends.

The course team checked what the SDK’s client gives the server, with SUPPORT_TENANT=bramble set in the shell:

the SDK passes by default: HOME, LOGNAME, PATH, SHELL, TERM, USER
1. StdioServerParameters(command, args, cwd) -> result, isError False: Charged twice for order LK-182074
2. ... env={'SUPPORT_TENANT': 'bramble'} -> error -32003: forbidden: you have no access to this organization

Without env, the server never saw bramble. SDK 2.3.0 passes only six safe variables on macOS and Linux, so the server used its defaults, Sam at Larkfield. With env, the host passed the organization on purpose. This is a good default. Your shell may hold keys and tokens, and a server should get only what its host chooses. Desktop assistants and code editors that start local servers have a configuration with the same parts. It names a command, its arguments and the environment variables. The course did not test them.

Use stdio when the server runs on the same computer as the host, for one person. Examples are an assistant on Sam’s laptop, a code editor and a test. It needs no network port, and no other computer can reach it. The person who starts it is the caller, so the environment is a fair place for the caller’s identity.

It also has costs:

  • One process for each host. Every host that connects starts its own copy. Each start costs about a quarter of a second here. The course measured a median of 296.5 ms to start the server and list its tools.
  • Only one computer. A partner’s app or a shared help desk cannot reach a process on Sam’s laptop. Module 5 adds Streamable HTTP, a transport over the network.
  • The identity is whoever started it. That is fine on a person’s own computer. It is not enough for a shared server, where each request needs its own proof of identity. Module 5 replaces the environment with access tokens.
  • Keep stdout clean. A single stray print() can break the stream, as the next box shows.

Common mistake

JSONDecodeError, or: Failed to parse JSONRPC message from server

Symptom: you add a print() to the server, and the strict script crashes:

answer = json.loads(out)
^^^^^^^^^^^^^^^
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

With the SDK’s client, the call still works, but stderr shows a long message that starts like this:

Failed to parse JSONRPC message from server
Traceback (most recent call last):
…
Invalid JSON: expected value at line 1 column 1 [type=json_invalid, input_value='support-mcp is starting', input_type=str]

Cause: a print() wrote plain text to stdout, the stream for protocol messages. The course team tested a greeting before run("stdio") and a debug print() inside get_ticket. SDK 2.3.0 moves stdout to stderr while it serves, but not always in time. In both tests, the text reached the SDK’s client. Other hosts may close the connection instead. The course did not test them.

Fix: write every log to stderr: print("debug:", ticket_id, file=sys.stderr), or use logs.py. Search the server for print( before each commit.

Common mistake

The tool ran, but no answer came back

Symptom: server-log.jsonl shows "outcome": "ok", but stdout printed nothing. This happens when the input closes right after the request, for example with echo '{…}' | python -m support_mcp.

Cause: the end of input tells the server to stop. It stopped after the tool ran, but before it wrote the answer. The course team saw exactly this: the log line with "outcome": "ok", an empty stdout and exit code 0.

Fix: keep stdin open until the answer arrives. A host does this; scripts/trace.py does it too. By hand, paste the line into the running server and wait for the answer before you press Ctrl+D.

Reset. If your project is broken, copy the snapshot end-of-m03 as in Where MCP fits, with end-of-m03 instead of start. It also has the next lesson’s README section.

Check your understanding

Name the three standard streams of the stdio server, and say what travels on each one.

Show the answer

stdin: the client’s JSON-RPC requests and notifications, one per line. stdout: the server’s answers, one per line, and nothing else. stderr: the server’s logs, here one JSON object per line from logs.py, plus the SDK’s own warnings. The host can show stderr or save it, but never reads it as MCP.

Check your understanding

Predict: a host starts support-mcp with the SDK's client, and its env sets SUPPORT_USER to usr-omar and nothing else. Omar is read-only at Larkfield. The client calls get_ticket for T-30002. What comes back, and why does the model have no way to change the organization?

Show the answer

A normal result with ticket T-30002, isError: false. Omar is a member of Larkfield, the default organization, and the default scopes include tickets:read; being read-only does not stop reading a ticket. The organization comes from SUPPORT_TENANT in the environment that the host chose. No tool has an organization argument, so nothing that the model writes can name another organization.

Check your understanding

Practical check: start python -m support_mcp with its stderr sent to server-log.jsonl, as in the steps above. Send one get_ticket request by hand and stop the server. What did the screen show, and what is in server-log.jsonl?

Show the answer

The screen shows one line, the answer: {"jsonrpc":"2.0","id":1,"result":…} with the ticket T-30002. The file holds one JSON line with "event": "tool_call", "user": "usr-sam", "tenant": "larkfield", "tool": "get_ticket" and "outcome": "ok". stdout carried the protocol, and stderr carried the log. If the screen shows the log line too, the 2> part of the command is missing.

Recap

  • With the stdio transport, the host starts the server as a child process. Requests go in on stdin, answers come out on stdout, and logs go to stderr.
  • A stray print() on stdout breaks the stream. Log to stderr, always.
  • The host decides the server’s environment. Larkfield’s server takes the caller from SUPPORT_USER, SUPPORT_TENANT and SUPPORT_SCOPES, and the role from its own membership table. No tool takes an organization.
  • The SDK’s client passes only a few safe variables; a host passes the rest with env.

Your project now has the server, scripts/trace.py and 40 tests. Next, you inspect the server with MCP Inspector, as any developer of another host would.

Next: Inspect behaviour