Remote transport
- Lesson 14
- 50 min
- Module 5: Remote access and authorization
Lesson 14 of 22 · MCP: Connect AI Applications to Tools and DataRemote 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
Make Larkfield’s MCP server reachable for more than one person and more than one AI application. You serve the same server over Streamable HTTP and send it one request by hand. Then you see what changes from a local process: the caller, the network and HTTPS.
Before you startWhat to know and to have ready
What you need to know
- How the stdio server learns its caller from the environment, from Local transport.
- How the course’s host discovers tools and runs them, from Client integration.
- Access tokens, their audience and their scopes, from OAuth and OIDC in Authentication, Permissions and Multi-Tenant Applications.
Your project
~/projects/support-mcpat the end of Module 4:python -m pytest -qprints49 passed.curl, which macOS, Linux and Windows 10 or later include. In PowerShell, typecurl.exe, notcurl.- Three terminal windows. In each one, go to
~/projects/support-mcpand activate the virtual environment.
A notebook and a shop counter
Section titled “A notebook and a shop counter”Sam keeps a notebook on his desk. Only he reads it, so it needs no lock and no list of readers. Whoever opens it is Sam.
Now the team wants the same information at a counter on the street. Many people come to the counter, all day. Three things change at once. The counter needs an address, so people can find it. Somebody must check a card for each visitor, every time, because anyone can walk up. And the walk to the counter happens in public, so the conversation needs a closed door.
The problem: the server lives on one laptop
Section titled “The problem: the server lives on one laptop”Until now, support-mcp ran as a child process on the same computer as its host. That works for Sam’s own assistant. It does not work for the help desk’s assistant, which runs on a shared server. It also does not work for Omar’s partner app, which runs at another company.
There is a second problem, and it is worse. The stdio server learns its caller from its environment. Local transport showed what happens when the environment says nothing: the server uses its defaults, Sam at Larkfield. If a shared service took its caller from its own environment, every person who connects would be Sam. Omar, Tomás and a stranger on the network would all read Larkfield’s tickets as staff.
So the remote server needs two new things. It needs a transport that works across a network. And it needs proof of the caller in each request, not in the process.
The idea: Streamable HTTP and a token in every request
Section titled “The idea: Streamable HTTP and a token in every request”Streamable HTTP is the MCP transport for a server that runs as a web service. The server listens at one URL, the MCP endpoint: here http://127.0.0.1:8000/mcp. The client sends each JSON-RPC message in the body of an HTTP POST to that URL. The server answers with one JSON body, or with a stream of events when it has more than one message to send. That stream is why the transport is called “streamable”.
In revision 2026-07-28, every request stands alone. It carries the same _meta envelope as over stdio, plus headers that repeat its key facts. MCP-Protocol-Version and Mcp-Method go with every request. Mcp-Name goes with a tool call, a resource read or a prompt request. The server keeps no session, that is, no memory of earlier requests from the same client. So a busy service can run several copies of the server, and any copy can answer any request.
Two older designs still appear in tutorials. Revision 2025-11-25 used Streamable HTTP with a session: after initialize, the server gave an Mcp-Session-Id header that every later request had to repeat. The first revision, 2024-11-05, had a different transport, HTTP+SSE, with two endpoints. Learn to recognize both as legacy. This course uses MCP specification revision 2026-07-28 with the official MCP Python SDK 2.3.0.
The caller comes from an access token, the hotel key card of the authentication course. The host sends it in the Authorization: Bearer … header of every request. The server checks it every time: the signature, the issuer, the expiry and the audience. The audience must be the server’s own URL, http://127.0.0.1:8000/mcp. The organization comes from a second header, X-Support-Tenant, which the host’s configuration sets. No tool argument names it, as before.
Text description of the diagram
Local, stdio. One person on one computer. The host starts support-mcp as a child process with SUPPORT_USER=usr-sam and SUPPORT_TENANT=larkfield, and talks to it on stdin and stdout. The caller is whoever started the process. There is no port, so no other computer can reach it, and no token.
Remote, Streamable HTTP. Many people and applications, one URL. The help desk assistant, an editor assistant and the partner app each have an MCP client. Each one sends POST /mcp with an Authorization: Bearer … header and an X-Support-Tenant header to http://127.0.0.1:8000/mcp.
The person signs in at the practice identity provider, http://127.0.0.1:8400, which signs access tokens. The server reads the provider’s public keys (JWKS) to check them. The caller is the user named in a valid token, for every request. Anything that can reach the port can send a request, so outside your own computer, use only HTTPS.
A tiny example: one request with curl
Section titled “A tiny example: one request with curl”Here is one real request, sent by hand with curl, and the server’s real answer. Amira sends the same tools/call for ticket T-30002 that you sent over stdio in Module 3. This time it travels as HTTP.
Text description of the diagram
The request has eight header lines and a body.
Authorization: Bearer eyJhbGciOiJSUzI1NiIs…: who. An access token for this server, here Sam’s.X-Support-Tenant: larkfield: where. The organization, from the host’s settings.Accept: application/json, text/event-stream: the client can read JSON or a stream. Withapplication/jsonalone, the server answers 406.MCP-Protocol-Version: 2026-07-28: which revision. Without it, the server treats the request as the legacy era.Mcp-Method: tools/callandMcp-Name: get_ticket: what. They must match the body, or the server answers −32020.
The body is one JSON-RPC request, with the same _meta envelope as over stdio. The answer is HTTP/1.1 200 OK with one JSON body of 1,324 bytes and isError: false. It has no Mcp-Session-Id header.
Compare it with stdio. The JSON-RPC message is the same, and so is the answer. It has 1,324 bytes here and 1,325 on stdio, where each line ends with a newline. Only the envelope around the message changed. The headers repeat the method and the tool name, so a proxy or a gateway can route or limit a request without reading its body.
Larkfield: serve the server over HTTP
Section titled “Larkfield: serve the server over HTTP”The project has the parts already: a Starlette app from the SDK, a token check and a small practice provider. You copy them from the snapshot end-of-m05. The provider is the subject of OAuth integration; here you only start it.
-
In terminal 3, copy this lesson’s files:
Terminal window S=../nextia-learning-labs/C28/snapshots/end-of-m05cp "$S"/support_mcp/{http,__main__,server,identity}.py support_mcp/cp "$S/scripts/local_http.py" scripts/cp -R "$S/idp" .cp "$S/tests/conftest.py" tests/cp "$S/.env.example" "$S/README.md" .Terminal window $S = "..\nextia-learning-labs\C28\snapshots\end-of-m05"foreach ($f in "http", "__main__", "server", "identity") { Copy-Item "$S\support_mcp\$f.py" support_mcp\ }Copy-Item "$S\scripts\local_http.py" scripts\Copy-Item -Recurse "$S\idp" .Copy-Item "$S\tests\conftest.py" tests\Copy-Item "$S\.env.example", "$S\README.md" . -
Run the tests. The stdio server must still work as before:
Terminal window python -m pytest -q................................................. [100%]49 passed in 7.29s -
In terminal 1, make the provider’s signing key once, then start the provider:
Terminal window python -m idp initpython -m idpSigning key b067a5e8442c44b0 in /Users/you/projects/support-mcp/.idpYour key ID and folder differ. The second command prints nothing and keeps running. It listens on
http://127.0.0.1:8400. -
In terminal 2, start the MCP server over HTTP:
Terminal window python -m support_mcp --httpStreamableHTTP session manager startedThis is an SDK log line on stderr. Its name comes from the older revision; requests of revision 2026-07-28 get no session. The server listens on
http://127.0.0.1:8000/mcp. -
In terminal 3, send a request with no token:
Terminal window curl -i -X POST http://127.0.0.1:8000/mcp -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}"HTTP/1.1 401 Unauthorizedcontent-type: application/jsonwww-authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"{"error": "invalid_token", "error_description": "Authentication required"}In PowerShell, type
curl.exe; its quoting of the JSON was not tested. The server refused before MCP even started.resource_metadatasays where a client can learn how to get a token. OAuth integration follows that link. -
Get a practice token for Sam, and look inside it:
Terminal window TOKEN=$(python -m idp token --user usr-sam)python -c 'import jwt,sys,json; print(json.dumps(jwt.decode(sys.argv[1], options={"verify_signature": False}), indent=2))' "$TOKEN"Terminal window $TOKEN = python -m idp token --user usr-sampython -c "import jwt,sys,json; print(json.dumps(jwt.decode(sys.argv[1], options={'verify_signature': False}), indent=2))" $TOKEN{"iss": "http://127.0.0.1:8400","sub": "usr-sam","aud": "http://127.0.0.1:8000/mcp","iat": 1791673121,"nbf": 1791673121,"exp": 1791673721,"jti": "1965650eff254c559a7c2302cc400f67","client_id": "support-host","scope": "knowledge:read tickets:read"}python -m idp tokenis a practice shortcut: it signs a token with no sign-in. The token names the user (sub), the server it is for (aud) and the allowed actions (scope). It lives 600 seconds. It names no organization and no role. -
Ask the host a question over HTTP:
Terminal window python -m host "What is the return window?" --http http://127.0.0.1:8000/mcp --token "$TOKEN" --tenant larkfieldIn PowerShell, write
--token $TOKEN.[host] discovered 2 tools: search_knowledge, get_ticket {"protocol": "2026-07-28", "server": "support-mcp"}[model] wants search_knowledge {"arguments": {"query": "What is the return window?"}}[host] read-only tool on the allow-list: run it {"decision": "auto"}[server] result: 1078 characters {"ms": 17.4, "clipped": false}[model] final answerFrom "Returns policy" (policy://larkfield/returns-policy): … The version that applies is the one in force on the date your order was delivered. ## Return window You can return an unused item …The mock model and the host are the same as in Module 4. Only the connection changed: a URL, a token and an organization instead of a child process.
Terminal 2 now shows a log line for the call: "user": "usr-sam", "tenant": "larkfield", "role": "staff", "client": "support-host". The user came from the token, the organization from the header, and the role from the membership table.
Read the new code
Section titled “Read the new code”support_mcp/__main__.py gained one option. Without --http, nothing changed. With it, the SDK’s Starlette app runs in uvicorn, a web server for Python:
if not a.http: from support_mcp.server import build_server
build_server(transport="stdio").run("stdio") return 0import uvicorn
from support_mcp.http import build_app
uvicorn.run(build_app(), host="127.0.0.1", port=a.port, log_level="warning")host="127.0.0.1" means “this computer only”. Another computer on your network cannot connect. That is the safe choice for practice, because the server has no HTTPS.
support_mcp/server.py changed one line. The caller now depends on the transport:
mcp.caller_of = (lambda ctx: identity.local_caller()) if transport == "stdio" else identity.http_callerThe tools did not change at all. get_ticket still asks “who is the caller?” first, then checks a scope, then reads one organization. Only the answer to “who?” comes from somewhere else. Identity and permission reads http_caller.
support_mcp/http.py builds the web app. It gives the SDK two things: a token verifier and the authorization settings:
mcp = build_server( knowledge, transport="http", token_verifier=verifier or JwtVerifier(issuer, resource_url), auth=AuthSettings( issuer_url=issuer, resource_server_url=resource_url, required_scopes=list(READ_SCOPES), validate_token_resource=False, # the verifier checks the audience itself ),)return mcp.streamable_http_app()JwtVerifier.check is the token check of the authentication course, with PyJWT. It allows the algorithm RS256 only and the type at+jwt, and takes the key from the provider’s JWKS. Then it checks the signature, the issuer, the audience and the expiry. If the check fails, the SDK answers 401. The reason goes only to the server’s log, as "event": "token_refused". resource_url is MCP_RESOURCE_URL, http://127.0.0.1:8000/mcp by default: the server’s own name, and so the audience it accepts.
Your turn: leave out one header
Section titled “Your turn: leave out one header”Change one thing at a time in the request of the tiny example. Before you run each command, predict the answer. The request body is in a file, so both shells can send it.
-
Save the request body as
call.jsonin the project folder:{"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":"curl","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} -
Send it with every header, as in the diagram:
Terminal window curl -i -X POST http://127.0.0.1:8000/mcp -H "Authorization: Bearer $TOKEN" -H "X-Support-Tenant: larkfield" -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" -H "MCP-Protocol-Version: 2026-07-28" -H "Mcp-Method: tools/call" -H "Mcp-Name: get_ticket" --data-binary "@call.json"In PowerShell, type
curl.exeand keep the rest. You getHTTP/1.1 200 OKand the ticket. -
Send it again without the last three headers.
-
Send it again with all headers, but with
Mcp-Name: search_knowledge.
Show the answers
Without the three MCP headers (step 3):
HTTP/1.1 400 Bad Requestcontent-type: application/jsonmcp-session-id: 1e0f4c83ef7c4e98bb43a1f32aaebcd0
{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Bad Request: Missing session ID"}}With no MCP-Protocol-Version header, SDK 2.3.0 treats the request as the legacy era. In that era, a client first sends initialize and then repeats the session ID. So the server even creates a session and sends its ID back. Terminal 2 shows Created new transport with session ID: … and Terminating session: …. The legacy era needs a session; the current one does not.
With the wrong Mcp-Name (step 4):
{"jsonrpc":"2.0","id":1,"error":{"code":-32020,"message":"mcp-name header does not match the request body's 'name' parameter"}}The headers repeat the body, so the server checks that they agree. A gateway that routes or limits by header can then trust the header. Without the Mcp-Name header, the answer is the same −32020.
What changes from stdio, and what it costs
Section titled “What changes from stdio, and what it costs”| Question | Local: stdio | Remote: Streamable HTTP |
|---|---|---|
| Who starts the server? | The host, as a child process | You, as a service that keeps running |
| Who is the caller? | The person who started it | The user in a valid access token, per request |
| How many callers? | One | Many, at the same time |
| Network | None | A port; here 127.0.0.1 only |
| Where do secrets travel? | Nowhere | The token, in every request |
This table is also the start of docs/remote-design.md, which you complete in OAuth integration.
Users. A local server has one caller for its whole life. A remote server can get a request from Sam and the next one from Ines. So the server checks a token in every request, and never keeps the caller from an earlier request.
Network. A port is an open door. Anything that can reach it can send a request, so the server must refuse what it cannot verify. This course binds to 127.0.0.1 and never opens the port to the network.
HTTPS. A bearer token works for whoever holds it, like a key card. On plain HTTP, anyone who can see the network traffic can copy it. Outside your own computer, serve the MCP endpoint and the provider over HTTPS only. The course did not test HTTPS, a real identity provider or a deployment to a cloud.
Speed. On the course team’s laptop, a warm get_ticket call took a median of 0.68 ms over stdio and 1.16 ms over HTTP. HTTP adds a little per call. But it saves the start of a process for each host. Starting the stdio server and listing its tools took a median of 296.5 ms. Your times will differ.
Use it when a server is shared: by several people, by an application on another computer, or by a partner. Do not use it for a tool that only you run on your own laptop. stdio is simpler there and opens no port. The cost is real work: a provider, keys, tokens that expire, a service to keep running, and HTTPS for anything beyond your computer.
Common mistakes
Section titled “Common mistakes”Common mistake
A long traceback that ends in: MCPError: Server returned an error response
Symptom: the host worked ten minutes ago. Now it prints 86 lines that end like this:
| mcp.shared.exceptions.MCPError: Server returned an error response +------------------------------------Cause: the token expired. Practice tokens live 600 seconds. The server answers every token problem with the same 401, so the client cannot tell why. The server’s log in terminal 2 says it:
{"ts": "…", "event": "token_refused", "reason": "expired", "ms": 0.3}The SDK’s client tried server/discover, got 401, then tried the legacy initialize, and got 401 again. So you see two token_refused lines for one run.
Fix: get a new token in the same terminal (TOKEN=$(python -m idp token --user usr-sam)), then run the host again. A real host refreshes the token or asks the person to sign in again.
Common mistake
Illegal header value b'Bearer '
Symptom: the host stops at once with httpx2.LocalProtocolError: Illegal header value b'Bearer '.
Cause: the token is empty. You left out --token, or you set $TOKEN in another terminal. Each terminal has its own variables.
Fix: set TOKEN in the terminal where you run the host. echo $TOKEN must print a long text that starts with eyJ.
Common mistake
address already in use, or 401 after you moved the port
Symptom 1: the server does not start:
ERROR: [Errno 48] error while attempting to bind on address ('127.0.0.1', 8000): [errno 48] address already in useAnother program, or a second copy of the server, uses port 8000. Stop the other copy with Ctrl+C, or use another port. The error number differs on Windows and Linux.
Symptom 2: you started the server with MCP_PORT=8001 and MCP_RESOURCE_URL=http://127.0.0.1:8001/mcp. Every request now gets 401, and terminal 2 logs "reason": "wrong_audience".
Cause: you made the token in a terminal where MCP_RESOURCE_URL still had its default. The token’s aud was http://127.0.0.1:8000/mcp, and the server on port 8001 accepts only its own URL. The course team saw exactly this: 401 with the old token, 200 with a token made for 8001.
Fix: set the same MCP_RESOURCE_URL in every terminal: the server’s terminal and the one that makes tokens. It must equal the URL that clients use, because it is the audience.
Stop the servers. Press Ctrl+C in terminals 1 and 2 when you finish. Keep .idp/: it holds the practice key, and the next lessons use it. .gitignore already lists it.
Reset. If your project is broken, copy the snapshot end-of-m04 as in Where MCP fits, then repeat step 1.
Knowledge check
Section titled “Knowledge check”Check your understanding
Name two things that the stdio server got from its environment, and say where the HTTP server gets each one instead.
Show the answer
The user: from SUPPORT_USER on stdio, from the sub of a valid access token over HTTP. The organization: from SUPPORT_TENANT on stdio, from the X-Support-Tenant header that the host’s configuration sets over HTTP. The scopes also move, from SUPPORT_SCOPES to the token’s scope. The role comes from the server’s membership table in both cases.
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: you send a correct tools/call to the HTTP server with a valid token, but without the MCP-Protocol-Version header. What comes back, and why?
Show the answer
HTTP 400 with the JSON-RPC error −32600 “Bad Request: Missing session ID”, and an mcp-session-id header. Without the version header, SDK 2.3.0 treats the request as the legacy era of Streamable HTTP. That era needs an initialize request first, and then the session ID in every request. Revision 2026-07-28 has no session, so a current client always sends the header.
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: with the provider and the server running, send tools/list with curl and no token. Which status and which header come back, and where does the header point?
Show the answer
HTTP/1.1 401 Unauthorized, with www-authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp". It points to the server’s protected resource metadata: the document that tells a client which authorization server issues tokens for this server.
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
- Streamable HTTP serves MCP at one URL, the MCP endpoint. Each JSON-RPC message is the body of an HTTP
POST. - In revision 2026-07-28, every request stands alone: the
_metaenvelope plus the headersMCP-Protocol-Version,Mcp-Methodand, for a call, a read or a prompt,Mcp-Name. There is no session. Sessions (Mcp-Session-Id) and HTTP+SSE are legacy. - The caller comes from an access token in every request, with the server’s own URL as its audience. The organization comes from the host’s
X-Support-Tenantheader. - A remote server is a door on a network: check every request, bind to
127.0.0.1for practice, and use HTTPS beyond your own computer.
Your project now serves the same tools over stdio and over HTTP, with 49 tests. Next, you see how the server turns a token and a header into a decision: allowed, or refused by which check.
Next: Identity and permission