Reaching an MCP server¶
An MCP server runs as a subprocess on a developer's machine and as an HTTP endpoint in
the cluster. McpTransport is the one seam between the two: everything above it — the
session, the client, the adapted tools, the agent — is written against the protocol, so
moving a server from a laptop to the cluster is a configuration change and never a code
change.
flowchart LR
Config[McpServerConfig<br/>transport: stdio or http] --> Factory[transport_for]
Factory --> Stdio[StdioTransport<br/>child process]
Factory --> Http[HttpTransport<br/>JSON or SSE]
Stdio --> Session[TransportSession]
Http --> Session
Session --> Client[McpClient]
Declare which one¶
from tesserix_adk.adapters import TransportSession, transport_for
from tesserix_adk.core.config import McpServerConfig
local = McpServerConfig(
name="handbook",
transport="stdio",
command=("uvx", "handbook-mcp"),
env_allow=("HOME", "PATH"),
)
cluster = McpServerConfig(
name="handbook",
transport="http",
endpoint="http://handbook.support-platform.svc.cluster.local/mcp",
)
session = TransportSession(transport_for(cluster), config=cluster)
A stdio server without an argv and an HTTP server given one are both refused when the configuration is read, rather than at the first call. The declaration resolves through the kit's normal precedence, so the same agent image runs against either by environment.
The ceilings both transports keep¶
| Setting | What it bounds |
|---|---|
max_message_bytes |
One message, on the wire or on a pipe. Past it, LIMIT. |
read_timeout_seconds |
How long a read may stall before the call fails. |
max_in_flight |
Requests outstanding on one connection at once. |
timeout_seconds |
One operation end to end. |
A limit only one transport enforces is a limit nobody can rely on, so both keep all four.
The child process¶
The child is spawned from an explicit argv — never a shell string — and inherits only the
variables in env_allow. A subprocess that inherits this process's environment inherits
its credentials, and an MCP server is exactly the thing that should not have them.
Its stderr is drained continuously so a full pipe cannot wedge it, and the last lines are
kept on stderr_tail for the failure that needs them. On close, on cancellation and on
failure the child is terminated, given five seconds, and then killed; returncode is set
by the time close() returns, so no orphan survives the run.
The endpoint¶
Redirects are never followed and anything that is not application/json or
text/event-stream is a transport failure: an intercepting proxy answering with an HTML
error page is precisely where parsing whatever arrived is the wrong thing to do. A 5xx is
UNAVAILABLE, a 4xx is PROTOCOL. Unreachable endpoints are tried three times with
jittered backoff, so many replicas do not amplify one outage, and then reported as one
failure rather than retried without end.
Failures¶
McpTransportError carries a reason, because a connection that dropped and a read that
never came call for different handling:
| Reason | What happened |
|---|---|
DISCONNECTED |
The child exited or the stream closed mid-call. |
TIMEOUT |
The read deadline fired on a stream that went quiet without closing. |
PROTOCOL |
The answer was not protocol, or was a JSON-RPC error. |
LIMIT |
A message ran past max_message_bytes. |
UNAVAILABLE |
The endpoint could not be reached, or the process would not start. |
.retryable is true for UNAVAILABLE and TIMEOUT only. Nothing here retries above the
transport's own bounded attempts; that policy is its own story.
The server's own error text never travels into the message. A run fails closed rather than receiving a partial or an invented response.
Testing without a server¶
RecordingTransport answers from a script and records what it was asked, so tool
behaviour is tested with no subprocess and no socket:
from tesserix_adk.adapters import McpClient, RecordingTransport, TransportSession
transport = RecordingTransport({"tools/list": {"tools": [...]}})
client = McpClient(TransportSession(transport, config=config), config=config)
A method nobody scripted is a PROTOCOL failure, so a test cannot pass by accident on a
call it never arranged.
Known limitations¶
McpTransportis public and changes only under the kit's deprecation policy, so a transport written outside the kit survives minor versions.- No authority is attached here: headers are passed through, and minting a credential belongs to the auth story.
- The SSE reader takes the first data event of a reply. Server-initiated streams of notifications are not read yet.
For a network-free executable composition, run
uv run python examples/mcp_transports.py.