Under mcp serve, native roots are cached for the life of the connection (McpRootsScope.Connection). That is correct, but it rests on an assumption the repository never states: one transport is one caller. A reviewer read the code, could not find that assumption anywhere, and filed a P1 for cross-caller disclosure — see #71 (thread on McpClientRootsService.cs:184). The finding is not a defect, but it was a reasonable reading, and it cost a round of analysis to refute.
What is true
The mechanism the reviewer described is real. On 2026-07-28 over mcp serve, the connection cache does fill, and a later request does read it without consulting its own request.Server. Measured:
era=2026-07-28 supported=True current=[file:///ga] get=[file:///ga] roundTrips=1
This corrected a belief of mine: I had assumed sessionless requests never populate that cache, on the strength of DestinationBoundMcpServer.ClientCapabilities being null in HttpServerSessionMode.Stateless. That is an HTTP-path fact and does not carry to RunAsync.
Why it is not exploitable
One ITransport is one peer by construction, not by convention:
- it exposes a single
ChannelReader<JsonRpcMessage> created with SingleReader = true, and one read loop;
- messages carry no source discriminator —
JsonRpcMessageContext has no peer member;
- there is no multiplexing primitive below the HTTP layer. Multiplexing lives in
ModelContextProtocol.AspNetCore, where each StreamableHttpSession owns its own transport and its own McpServer.
A host merging two clients onto one pipe would collide their JSON-RPC request IDs long before roots leaked. Every shape that legitimately carries several callers already uses the reused BuildMcpServerOptions() pattern, which is McpRootsScope.Request and is guarded by Given_McpSharedServerOptions.
Why no enforcement is possible
Checked exhaustively; there is no per-caller key on this path:
| candidate |
on stdio / StreamServerTransport |
Server.SessionId |
null in every revision — 2026-07-28 removed session IDs (SEP-2567) |
MessageContext.User |
populated only by the ASP.NET Core transports; unconditionally null here |
Server.ClientInfo / ClientCapabilities |
client-asserted capability data, not identity |
Services |
per-connection, or a per-request scope |
request.Server |
fresh DestinationBoundMcpServer per message |
_meta |
no protocol-reserved identity key |
The only latent seam is TransportBase.SessionId, whose setter is protected — a custom transport could populate it, if enforcement ever becomes wanted.
Work
- State single-peer as a requirement, with its reason. In
docs/mcp-transports.md Scenario A, at the TransportFactory sample: the returned transport serves exactly one client, because messages on one transport carry no source discriminator. Point hosts wanting several clients at the "Multi-session custom transports" pattern just below — already the right advice, merely unlabelled as the alternative.
- Isolation table: the
Connection (mcp serve only) row is per transport, and per-caller only because one transport is one caller.
TransportFactory XML doc on the MCP options type: carry the one-client requirement where a host reads it while writing the bridge.
- Comment the assumption at the cache — near
CreateSessionContext(McpRootsScope.Connection) in McpServerHandler.RunAsync, including that no per-caller key exists to validate against, so the next reader does not go looking.
- Guard the cost model in
Given_McpConcurrentSessions.cs: on 2026-07-28 under mcp serve, two tool calls on one connection must cost exactly one roots/list. Reuse BuildCountingRootsClientOptions and StartSessionAsync. Today's behaviour becomes deliberate, and a later switch to request scope turns it red rather than drifting.
Rejected
Request-scoped roots for sessionless requests under mcp serve (the reviewer's own remedy). It would cost one roots/list per request for the single peer that path serves, to defend a topology the transport contract already forbids.
Related: #70 — both concern where a scope boundary falls.
Under
mcp serve, native roots are cached for the life of the connection (McpRootsScope.Connection). That is correct, but it rests on an assumption the repository never states: one transport is one caller. A reviewer read the code, could not find that assumption anywhere, and filed a P1 for cross-caller disclosure — see #71 (thread onMcpClientRootsService.cs:184). The finding is not a defect, but it was a reasonable reading, and it cost a round of analysis to refute.What is true
The mechanism the reviewer described is real. On
2026-07-28overmcp serve, the connection cache does fill, and a later request does read it without consulting its ownrequest.Server. Measured:This corrected a belief of mine: I had assumed sessionless requests never populate that cache, on the strength of
DestinationBoundMcpServer.ClientCapabilitiesbeing null inHttpServerSessionMode.Stateless. That is an HTTP-path fact and does not carry toRunAsync.Why it is not exploitable
One
ITransportis one peer by construction, not by convention:ChannelReader<JsonRpcMessage>created withSingleReader = true, and one read loop;JsonRpcMessageContexthas no peer member;ModelContextProtocol.AspNetCore, where eachStreamableHttpSessionowns its own transport and its ownMcpServer.A host merging two clients onto one pipe would collide their JSON-RPC request IDs long before roots leaked. Every shape that legitimately carries several callers already uses the reused
BuildMcpServerOptions()pattern, which isMcpRootsScope.Requestand is guarded byGiven_McpSharedServerOptions.Why no enforcement is possible
Checked exhaustively; there is no per-caller key on this path:
StreamServerTransportServer.SessionIdnullin every revision —2026-07-28removed session IDs (SEP-2567)MessageContext.UsernullhereServer.ClientInfo/ClientCapabilitiesServicesrequest.ServerDestinationBoundMcpServerper message_metaThe only latent seam is
TransportBase.SessionId, whose setter isprotected— a custom transport could populate it, if enforcement ever becomes wanted.Work
docs/mcp-transports.mdScenario A, at theTransportFactorysample: the returned transport serves exactly one client, because messages on one transport carry no source discriminator. Point hosts wanting several clients at the "Multi-session custom transports" pattern just below — already the right advice, merely unlabelled as the alternative.Connection (mcp serve only)row is per transport, and per-caller only because one transport is one caller.TransportFactoryXML doc on the MCP options type: carry the one-client requirement where a host reads it while writing the bridge.CreateSessionContext(McpRootsScope.Connection)inMcpServerHandler.RunAsync, including that no per-caller key exists to validate against, so the next reader does not go looking.Given_McpConcurrentSessions.cs: on2026-07-28undermcp serve, two tool calls on one connection must cost exactly oneroots/list. ReuseBuildCountingRootsClientOptionsandStartSessionAsync. Today's behaviour becomes deliberate, and a later switch to request scope turns it red rather than drifting.Rejected
Request-scoped roots for sessionless requests under
mcp serve(the reviewer's own remedy). It would cost oneroots/listper request for the single peer that path serves, to defend a topology the transport contract already forbids.Related: #70 — both concern where a scope boundary falls.