architecture/mcp-client-layer

MCP Client/Session/Transport Layer

A tour of src/main/mcp/ — session pooling, transport construction, protocol version pinning, OAuth, and handler wiring.

This is the heart of MCPFlo’s main process — everything involved in establishing, maintaining, and calling into an MCP connection lives here. Each file has a narrow, specific job.

src/main/mcp/
├── types.ts             Shared internal types (Session, handlers, ActiveCall)
├── transportFactory.ts  Builds the SDK transport for stdio or Streamable HTTP
├── protocolVersion.ts   Pins the negotiated protocol version, if overridden
├── oauthHandshake.ts    Drives the OAuth 2.1 authorization-code + PKCE flow
├── session.ts           Owns the pool of live sessions, one per server
├── sessionWiring.ts     Wires notification/elicitation/sampling handlers onto a Client
└── toolCalls.ts         Invokes tools over a warm session

session.ts — the session pool

Live sessions are kept in a Map<serverId, Promise<Session>>, keyed by server ID. Storing the promise rather than the resolved session means concurrent first-callers share a single in-flight connection attempt instead of racing to spawn duplicate processes for the same server. A warm session is reused across calls until it’s disconnected or dies; resolveSession treats a failed connection as simply absent, so a prior failure doesn’t wedge the pool.

Connecting a fresh session composes the other files in this directory: it builds a transport (transportFactory), pins a protocol version if one was set (protocolVersion), goes through the OAuth handshake if needed (oauthHandshake), and wires up handlers (sessionWiring) — see Cancelling & Refreshing Discovery for how an AbortSignal passed into this path lets an in-flight connect be cancelled.

transportFactory.ts — building the transport

createTransport(config) switches on config.transport.type to construct either a StdioClientTransport or a StreamableHTTPClientTransport from the MCP SDK. For stdio, it deliberately does not inherit the full host environment — only a safe baseline (PATH, HOME, etc.) via getDefaultEnvironment(), with PATH corrected via resolveShellPath() (see Process Model → shellPath.ts) so tools like npx resolve correctly, then layers the user’s explicitly configured environment variables on top. This means secrets living in MCPFlo’s own process environment can never leak into a spawned server. Everything downstream of transport construction works against the SDK’s generic Transport interface, so the rest of the session logic is transport-agnostic.

protocolVersion.ts — version pinning

The MCP SDK hardcodes its latest supported protocol version into every initialize request with no built-in option to request a different one. pinRequestedProtocolVersion(client, version) works around this by intercepting the client’s own request() method and rewriting the protocolVersion param specifically on outgoing initialize calls — a single seam that covers stdio, plain Streamable HTTP, and OAuth reconnects alike. This is what powers Protocol Version Overrides.

oauthHandshake.ts — the OAuth flow

Drives the authorization-code + PKCE flow described in Authentication & OAuth 2.1: builds an OAuth-aware transport, starts a loopback listener to capture the redirect, and coordinates with oauthProvider.ts and oauthStore.ts (one level up, in src/main/) for Dynamic Client Registration and encrypted token storage. assertCredentialSafe (imported from transportFactory.ts) enforces the cleartext-HTTP restriction for non-loopback hosts.

sessionWiring.ts — handlers

wireSession attaches the pieces of an MCP session that react to server-initiated messages rather than client-initiated calls:

  • Protocol housekeeping notifications the server may emit at any point (e.g. right after the handshake, while registering capability-gated tools).
  • elicitation/create requests, routed to an ElicitationHandler — see Elicitation.
  • sampling/createMessage requests, routed to a SamplingHandler — see Sampling.

toolCalls.ts — invoking tools

callTool() invokes a tool over a session’s warm, pooled connection, obtained via getSession(). Calls to the same server are serialized — a session has a single active call slot that the transport’s notification taps and elicitation handler read from — so an in-flight call unambiguously owns that slot. Calls to different servers run fully independently, so a slow call to one server never blocks another. This is what backs Live Notifications, and the serialization detail is why a single server’s history entries are always chronological.

types.ts — shared internal shapes

Defines the internal Session, ActiveCall, ElicitationHandler, and SamplingHandler types used across the files above — each handler type carries an AbortSignal so a server-side cancellation (e.g. the server’s own elicitation timeout firing) can unwind the client-side wait cleanly.

Edit this page on GitHub