Skip to content
tnsaiDocs
GitHub

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

TransportWireUse it for
StdioTransportThe stdin and stdout of a child processLocal servers started with npx, uvx or a binary
StreamableHttpTransportHTTP POST; replies as JSON or an event streamRemote servers on the current MCP HTTP transport
SSETransportA long-lived GET event stream plus POSTsRemote servers on the older HTTP+SSE transport
HttpTransportOne HTTP POST per messagePlain 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 methodDefault
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 methodDefault
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:

  1. A POST of a JSON-RPC ping. A 200 answer that is JSON-RPC or an event stream gives a StreamableHttpTransport.
  2. A GET with Accept: text/event-stream. A 200 event-stream answer gives an SSETransport.
  3. 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.

StepCallReturns
Find the endpointsMcpOAuthDiscovery.discover(serverUrl)OAuthMetadata, read from <serverUrl>/.well-known/oauth-authorization-server
Register a client, if the server asks for itMcpClientRegistration.register(registrationEndpoint, request)RegistrationResponse with clientId and clientSecret
Make a PKCE challengePkceFlow.generateChallenge()PkceChallenge with a verifier and an S256 challenge
Send the user to sign inPkceFlow.buildAuthorizationUrl(authorizationEndpoint, clientId, redirectUri, scope, state, challenge)The URL
Swap the code for tokensPkceFlow.exchangeCode(tokenEndpoint, clientId, code, redirectUri, challenge.codeVerifier())TokenResponse
RefreshPkceFlow.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.