MCP transports
A transport moves JSON-RPC messages between an McpClient and an MCP server. tnsai-mcp ships four transports in com.tnsai.mcp.client, a detector that picks one for a URL, and OAuth helpers in com.tnsai.mcp.auth. You hand the transport to McpClient and let the client do the JSON-RPC work; you rarely call the transport yourself.
Pick a transport
| Transport | Wire | Use it for |
|---|---|---|
StdioTransport | The stdin and stdout of a child process | Local servers started with npx, uvx or a binary |
StreamableHttpTransport | HTTP POST; replies as JSON or an event stream | Remote servers on the current MCP HTTP transport |
SSETransport | A long-lived GET event stream plus POSTs | Remote servers on the older HTTP+SSE transport |
HttpTransport | One HTTP POST per message | Plain request-response servers |
Every transport implements McpTransport: connect(), send(message), setOnMessage(consumer), setOnError(consumer), disconnect() and isConnected(). connect() and send() throw IOException.
package com.example.tnsai.docs;
import com.tnsai.mcp.auth.McpTokenManager;
import com.tnsai.mcp.auth.PkceFlow;
import com.tnsai.mcp.client.HttpInterceptor;
import com.tnsai.mcp.client.HttpTransport;
import com.tnsai.mcp.client.McpClient;
import com.tnsai.mcp.client.McpTransport;
import com.tnsai.mcp.client.SSETransport;
import com.tnsai.mcp.client.StdioTransport;
import com.tnsai.mcp.client.StreamableHttpTransport;
import com.tnsai.mcp.client.TransportDetector;
import java.io.IOException;
import java.net.http.HttpRequest;
import java.time.Duration;
import java.util.List;
import java.util.Map;
/** Compilation contract for mcp/transports.md. */
public final class McpTransportsExample {
private McpTransportsExample() {
}
static McpTransport localServer() {
return StdioTransport.builder()
.command("npx", "-y", "@modelcontextprotocol/server-filesystem", "/srv/data")
.environment("NODE_ENV", "production")
.workingDirectory("/srv")
.maxRetries(3) // restarts after a crash
.healthCheckInterval(Duration.ofSeconds(15))
.build();
}
static McpTransport remoteServer(String token) {
return StreamableHttpTransport.builder()
.endpoint("https://mcp.example.com/mcp")
.apiKey(token) // sent as "Authorization: Bearer <token>"
.timeout(Duration.ofSeconds(60))
.build();
}
static McpTransport legacySseServer(String token) {
return SSETransport.builder()
.sseEndpoint("https://mcp.example.com/sse")
.messageEndpoint("https://mcp.example.com/messages") // optional: the server can advertise it
.apiKey(token)
.build();
}
static McpTransport plainHttpServer(String token) {
return HttpTransport.withApiKey("https://mcp.example.com/rpc", token);
}
static McpTransport detected() throws IOException {
return TransportDetector.detect("https://mcp.example.com/mcp", Map.of("X-Team", "docs"));
}
static McpClient connect(McpTransport transport) throws Exception {
McpClient client = McpClient.create(transport);
client.connect();
client.initialize();
return client;
}
static McpTransport signedIn(String serverUrl, String tokenEndpoint, String clientId,
String code, String redirectUri, PkceFlow.PkceChallenge challenge)
throws IOException {
McpTokenManager tokens = new McpTokenManager();
tokens.storeToken(serverUrl,
PkceFlow.exchangeCode(tokenEndpoint, clientId, code, redirectUri, challenge.codeVerifier()));
HttpInterceptor bearer = chain -> {
String header = tokens.getValidToken(serverUrl, tokenEndpoint, clientId) // refreshes when close to expiry
.map(token -> token.tokenType() + " " + token.accessToken())
.orElseThrow(() -> new IOException("Sign in again"));
return chain.proceed(HttpRequest.newBuilder(chain.request(), (name, value) -> true)
.setHeader("Authorization", header)
.build());
};
return new HttpTransport(serverUrl, Map.of(), Duration.ofSeconds(30), List.of(bearer));
}
}Stdio
StdioTransport.builder() needs command(...); StdioTransport.create(command...) is the short form with the defaults. The transport starts the process on connect() and writes one JSON message per line.
| Builder method | Default |
|---|---|
environment(key, value), environment(map) | The parent environment |
workingDirectory(dir) | The current directory |
processStartTimeout(duration) | 10 seconds |
healthCheckInterval(duration) | 30 seconds; zero turns the check off |
autoReconnect(boolean) | true |
maxRetries(n) | 5 |
initialRetryDelay(duration), maxRetryDelay(duration) | 500 ms and 30 seconds, doubling in between |
shutdownTimeout(duration) | 5 seconds |
The health check only tests whether the process is still alive; it sends nothing to the server. When the process exits, its output stream fails or the check finds it dead, the transport calls setOnCrashDetected with a DisconnectReason, then setOnDisconnect, and restarts the process until maxRetries restarts have failed. After that it reports an IOException to the error consumer.
Streamable HTTP
StreamableHttpTransport.builder() needs endpoint(url). apiKey(key) adds Authorization: Bearer <key> to the headers set with headers(map). The request timeout is 30 seconds unless you set timeout(duration).
The transport keeps the Mcp-Session-Id the server returns and sends it on every later request. If the server answers 404 for a known session, the transport drops the session and send throws an IOException; initialize the client again to start a new session. Server-initiated notifications need a separate stream: call openNotificationStream() after the client has initialized.
SSE
SSETransport.builder() needs sseEndpoint(url). The server normally names its message endpoint in an endpoint event; a relative path is resolved against the SSE endpoint, and an endpoint on another origin is rejected. Set messageEndpoint(url) to fix it yourself, which also trusts that URL's origin.
| Builder method | Default |
|---|---|
connectTimeout(duration) | 30 seconds |
readTimeout(duration) | 5 minutes |
maxRetries(n) | 5 reconnects of the event stream |
initialRetryDelay(duration), maxRetryDelay(duration) | 1 second and 30 seconds |
apiKey(key) replaces any headers set before it with the single Authorization header; call headers(map) instead when you need more than one. SSETransport.withApiKey(sseEndpoint, messageEndpoint, apiKey) is the short form.
Plain HTTP
HttpTransport has no builder. Create it with new HttpTransport(endpoint, headers), add a timeout (30 seconds by default) and a list of HttpInterceptors with the longer constructors, or use HttpTransport.withApiKey(endpoint, key) and HttpTransport.from(McpServerConfig).
An HttpInterceptor sees each request before it is sent and each response before the transport reads it. It can rewrite the request, retry it or answer from a cache. Interceptors run in the order given, on the thread that called send. signedIn in the example uses one to set a fresh OAuth token on every request.
Detect the transport
TransportDetector.detect(endpoint) and detect(endpoint, headers) probe a URL and return a transport that is not yet connected:
- A POST of a JSON-RPC
ping. A 200 answer that is JSON-RPC or an event stream gives aStreamableHttpTransport. - A GET with
Accept: text/event-stream. A 200 event-stream answer gives anSSETransport. - Otherwise an
HttpTransport.
Each probe waits up to 10 seconds. There is nothing to detect for a local command; build a StdioTransport.
OAuth
The classes in com.tnsai.mcp.auth run the OAuth authorization-code flow with PKCE. All of them except the token manager are static.
| Step | Call | Returns |
|---|---|---|
| Find the endpoints | McpOAuthDiscovery.discover(serverUrl) | OAuthMetadata, read from <serverUrl>/.well-known/oauth-authorization-server |
| Register a client, if the server asks for it | McpClientRegistration.register(registrationEndpoint, request) | RegistrationResponse with clientId and clientSecret |
| Make a PKCE challenge | PkceFlow.generateChallenge() | PkceChallenge with a verifier and an S256 challenge |
| Send the user to sign in | PkceFlow.buildAuthorizationUrl(authorizationEndpoint, clientId, redirectUri, scope, state, challenge) | The URL |
| Swap the code for tokens | PkceFlow.exchangeCode(tokenEndpoint, clientId, code, redirectUri, challenge.codeVerifier()) | TokenResponse |
| Refresh | PkceFlow.refreshToken(tokenEndpoint, clientId, refreshToken) | TokenResponse |
The network calls throw IOException. TokenResponse holds accessToken, refreshToken, tokenType, expiresIn, scope and obtainedAt, and answers isExpired() and isExpiringSoon(threshold).
McpTokenManager keeps one token per server URL. storeToken(serverUrl, token) saves it; getValidToken(serverUrl, tokenEndpoint, clientId) returns it, refreshing it first when it expires within the threshold (5 minutes, or the Duration passed to the constructor). If the refresh fails, the old token is returned while it is still valid; an expired token that cannot be refreshed is dropped and the result is empty. signedIn in the example above stores the first token and reads a valid one for every request.
OAuthMcpTransport wraps another transport and asks the token manager for a valid token before connect() and each send(), which refreshes it in the manager. It does not add the token to the wrapped transport's requests: the HTTP transports fix their headers when they are built. Put the token on each request yourself with an interceptor, as signedIn does.