Local transport
- Lesson 9
- 55 min
- Module 3: Build and inspect a local server
Lesson 9 of 22 · MCP: Connect AI Applications to Tools and DataLocal transport
1. Understand the integration problem
3. Build and inspect a local server
4. Connect an AI application
5. Remote access and authorization
6. Protect tools and user control
7. Operate and maintain the integration
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 ready
What you need to know
- The four shapes of a JSON-RPC message, from JSON-RPC messages.
- The capability contracts of
support-mcp, from Tools, Resources and Prompts. - Organizations, memberships and roles, from Users and organizations, and scopes, from OAuth and OpenID Connect, in Authentication, Permissions and Multi-Tenant Applications.
Your project
~/projects/support-mcpwith the fresh environment of Choose a maintained SDK:python -m pytest -qprints20 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.
A kitchen with a hatch
Section titled “A kitchen with a hatch”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”.
The problem: Amira’s first server
Section titled “The problem: Amira’s first server”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.
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.
A tiny example: one call, every step
Section titled “A tiny example: one call, every step”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.
Text description of the diagram
- The host starts the child process, with
SUPPORT_USER,SUPPORT_TENANTandSUPPORT_SCOPESin its environment. - At 0.1 ms, the host writes
tools/call get_ticketfor T-30002 (id 1) on the server’s stdin: 325 bytes. - Python and the SDK start. The first answer of a new server waits about 250 to 290 ms.
- At 285.2 ms, the server writes one JSON log line on stderr:
"event": "tool_call", "tool": "get_ticket", "outcome": "ok". - At 285.7 ms, the answer arrives on stdout: a result with id 1,
isError: false, 1,325 bytes. - 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.
Larkfield: add the server
Section titled “Larkfield: add the server”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.
-
In
~/projects/support-mcp, with the virtual environment active, copy this lesson’s files:Terminal window S=../nextia-learning-labs/C28/snapshots/end-of-m03cp "$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" .Terminal window $S = "..\nextia-learning-labs\C28\snapshots\end-of-m03"foreach ($f in "__main__", "server", "identity", "logs") { Copy-Item "$S\support_mcp\$f.py" support_mcp\ }Copy-Item "$S\scripts\trace.py" scripts\Copy-Item "$S\tests\test_server.py", "$S\tests\test_stdio.py" tests\Copy-Item "$S\.env.example", "$S\README.md" . -
Run the tests:
Terminal window python -m pytest -q........................................ [100%]40 passed in 2.51sThe 20 new tests start the server in many ways. Five of them start it as a real child process.
-
Send the five discovery requests by hand:
Terminal window python -m scripts.trace discovery --width 90== discovery0.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 90cuts 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. -
Call one tool, and watch for the log line:
Terminal window python -m scripts.trace valid-call --width 90== valid-call0.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. Thetsfield 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.
Read the server
Section titled “Read the server”Start where the process starts, in support_mcp/__main__.py:
def main() -> int: from support_mcp.server import build_server
build_server().run("stdio") return 0run("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:
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:
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:
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.
See the two streams apart
Section titled “See the two streams apart”-
Start the server yourself, and send its stderr to a file:
Terminal window python -m support_mcp 2> server-log.jsonl2>sends stream number 2, stderr, to a file. stdout stays on the screen. This works the same in PowerShell. -
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. -
Stop the server with Ctrl+D (Windows: Ctrl+Z, then Enter), and read the file:
Terminal window cat server-log.jsonlTerminal window Get-Content 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.
-
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.
SUPPORT_TENANT=bramble python -m scripts.trace valid-call --width 90SUPPORT_USER=usr-camille SUPPORT_TENANT=bramble python -m scripts.trace other-tenant --width 90$env:SUPPORT_TENANT = "bramble"; python -m scripts.trace valid-call --width 90$env:SUPPORT_USER = "usr-camille"; python -m scripts.trace other-tenant --width 90Remove-Item Env:SUPPORT_USER, Env:SUPPORT_TENANTShow 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.
How a host starts the server
Section titled “How a host starts the server”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:
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, USER1. StdioServerParameters(command, args, cwd) -> result, isError False: Charged twice for order LK-1820742. ... env={'SUPPORT_TENANT': 'bramble'} -> error -32003: forbidden: you have no access to this organizationWithout 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.
When to use stdio, and what it costs
Section titled “When to use stdio, and what it costs”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 mistakes
Section titled “Common mistakes”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 serverTraceback (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.
Knowledge check
Section titled “Knowledge check”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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
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_TENANTandSUPPORT_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