Skip to content

fix(server): expire idle Streamable HTTP sessions and cap their count - #1032

Open
devcrocod wants to merge 5 commits into
mainfrom
devcrocod/streamable-http-session-limits
Open

devcrocod wants to merge 5 commits into
mainfrom
devcrocod/streamable-http-session-limits

Conversation

@devcrocod

@devcrocod devcrocod commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

mcpStreamableHttp kept every session until DELETE, so an initialize flood could exhaust memory. Idle sessions now expire, and new ones are capped.

StreamableHttpClientTransport now reports a 404 on its GET stream through onError instead of dropping the stream quietly, so a client learns that its session has ended.

closes #922
closes #715

How Has This Been Tested?

Unit tests, testApplication tests, and integration tests against a real CIO server with abrupt client disconnects and with the SDK's own client.

Breaking Changes

  • mcpStreamableHttp JVM signature changed; recompile.
  • Idle sessions expire after 30 min without requests; an open GET stream does not count. An expired session answers 404, and the client has to start a new session: StreamableHttpClientTransport does not do it by itself.
  • GET without Mcp-Session-Id returns 400.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Copilot AI balanced review requested due to automatic review settings September 29, 2026 16:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Session-expiry and existing-plugin races can still return incorrect responses or retain disconnected handlers.

Review effort: Balanced
Findings: 2 Medium severity

Open (2)
What changed in this PR

Adds bounded lifecycle management for stateful Streamable HTTP sessions to reduce resource exhaustion risk.

Changes:

  • Adds idle expiry and configurable session caps.
  • Improves SSE/transport cleanup after disconnects and closure.
  • Adds documentation and extensive lifecycle, concurrency, and integration tests.
File Description
README.md Documents session limits and configuration.
StreamableHttpStreamReleaseTest.kt Tests stream release and closed transports.
StreamableHttpSessionManagerTest.kt Tests manager expiry and concurrency.
StreamableHttpSessionLimitsTest.kt Tests endpoint limits and request-ID handling.
StreamableHttpServerTransportTest.kt Tests non-blocking SSE closure.
StreamableHttpResponseDeliveryTest.kt Tests GET session validation.
KtorRouteExtensionsTest.kt Tests legacy SSE session cleanup.
AbstractKtorExtensionsTest.kt Extracts shared SSE test helpers.
StreamableHttpSessionManager.kt Implements session tracking, expiry, and caps.
StreamableHttpServerTransport.kt Improves stream cleanup and closed-session handling.
KtorServer.kt Integrates lifecycle management into Ktor routes.
kotlin-sdk-server.klib.api Updates Kotlin/Native API signature.
kotlin-sdk-server.api Updates JVM API signature.
ClientDisconnectIntegrationTest.kt Tests abrupt disconnects against CIO.
run-conformance.sh Allows readiness checks to accept non-2xx responses.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really solid, carefully-tested hardening work — just one thing on the idle-expiry interaction with open GET streams to sort out before merge.


Produced by Air Automations. Name: Code review / Run: https://air.jetbrains.cloud/org/05cf1a7f-6ab5-713b-abd3-29d0c8a05e2d/automations/409567ec-d3a7-40ac-ba95-8518ec577ac2?run=43bdcce8-f456-4627-a562-18e9d63e7dc4

Copilot AI balanced review requested due to automatic review settings September 29, 2026 21:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Request-ID uniqueness and inherited request-lifecycle configuration remain incorrect in supported scenarios.

Review effort: Balanced
Findings: None

Resolved since last review (2)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Reject request IDs reused within the same session

kotlin-sdk-server/​src/​commonMain/​kotlin/​io/​modelcontextprotocol/​kotlin/​sdk/​server/​StreamableHttpServerTransport.kt:560

This only rejects IDs that are still present in the routing map, but cleanUpRequests removes them after completion. MCP 2025-11-25 requires that a request ID MUST NOT have been previously used by the requestor within the same session, so a completed ID is accepted here. Keep a separate session-lifetime set of all observed IDs (atomically reserving an entire batch) and clear it only when the transport closes.

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really nice work on the leak and the cap — one blocking comment inline to settle before this goes in.

Produced by Air Automations. Name: Code review / Run: https://air.jetbrains.cloud/org/05cf1a7f-6ab5-713b-abd3-29d0c8a05e2d/automations/409567ec-d3a7-40ac-ba95-8518ec577ac2?run=8ea27db8-18d5-42d3-8a52-2cd9c1995e67

Copilot AI balanced review requested due to automatic review settings September 29, 2026 23:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Late responses can be misrouted after request-ID reuse, inherited Ktor configuration can preserve disconnect leaks, and readiness probing accepts server errors.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)

Comment thread conformance-test/run-conformance.sh

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough work here — one inline finding needs a fix before this can go in.

Produced by Air Automations. Name: Code review / Run: https://air.jetbrains.cloud/org/05cf1a7f-6ab5-713b-abd3-29d0c8a05e2d/automations/409567ec-d3a7-40ac-ba95-8518ec577ac2?run=127d2dd9-a97f-47d6-bd78-235c7cca50e0

Copilot AI balanced review requested due to automatic review settings September 30, 2026 17:31
@devcrocod
devcrocod force-pushed the devcrocod/streamable-http-session-limits branch from 3c7e370 to 99617e5 Compare September 30, 2026 17:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Session registration can race with closure, and inherited Ktor lifecycle configuration can leave disconnect cancellation disabled.

Review effort: Balanced
Findings: 2 High severity · 1 Medium severity

Open (3)

@devcrocod
devcrocod requested a review from e5l September 30, 2026 17:45

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really nice work on this one — just one blocking inline finding to sort out before merge, and it should be a small fix.

@devcrocod
devcrocod force-pushed the devcrocod/streamable-http-session-limits branch from 99617e5 to b0070ae Compare October 13, 2026 20:36
Copilot AI balanced review requested due to automatic review settings October 13, 2026 20:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Session-expiry races, incomplete request-ID isolation, and inherited lifecycle configuration can still produce incorrect behavior.

1 open finding
3 resolved since last review

🧠 Review effort: Balanced

Comment on lines +582 to +584
// Touched so that the session cannot expire before sse {} runs.
if (sessions.touch(sessionId) == null) {
call.rejectSessionNotFound()

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really nice hardening work here — one blocking inline finding about the scope of the new lifecycle install to settle before merge.


Produced by Air Automations. Name: Code review / Run: https://air.jetbrains.cloud/org/05cf1a7f-6ab5-713b-abd3-29d0c8a05e2d/automations/409567ec-d3a7-40ac-ba95-8518ec577ac2?run=9ca4a0e2-2804-48de-ae2e-f318308c9017

}

installDnsRebindingProtection(enableDnsRebindingProtection, allowedHosts, allowedOrigins)
installCancelCallOnClose()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this installs HttpRequestLifecycle { cancelCallOnClose = true } on the receiver route, and for the canonical entry points that receiver is the routing root — Application.mcp does routing { mcp(...) }, and the README's Route.mcp example is routing { mcp { MyServer() } }. So the whole application silently switches to "cancel the handler when the client disconnects", not just the MCP endpoints. mcpStreamableHttp gets this right by installing inside route(path); the legacy SSE path does not.

Failure scenario: an app that serves MCP next to its own routes, e.g. Application.mcp { MyServer() } plus post("/orders") { chargeCard(); saveOrder() }. Today a client that disconnects mid-POST still gets its order saved; after this upgrade the handler is cancelled at the next suspension point, so the card is charged and the order is never written. Ktor's own docs call this out: keep cancelCallOnClose = false "when you need to complete processing regardless of client connection status (e.g., important side effects, database transactions)". Verified against a real CIO server on b0070ae — an unrelated get("/slow") whose client drops the socket:

routing { mcp { ... } }      -> cancelled=true  completed=false
mcpStreamableHttp { ... }    -> cancelled=false completed=true

and with this one line removed, routing { mcp { ... } } gives cancelled=false completed=true, so this line is the cause. It is also not in the PR's Breaking Changes list.

Suggested fix: keep the install inside the routes MCP actually registers, the way mcpStreamableHttp keeps it inside route(path). In this overload that means scoping it to the SSE GET:

method(HttpMethod.Get) {
    installCancelCallOnClose()
    sse { mcpSseEndpoint("", transportManager, maxRequestBodySize, block) }
}

I confirmed that all five ClientDisconnectIntegrationTest cases (including an SSE session ends when its client disconnects) still pass with that change, while /slow is no longer cancelled. Worth pinning with a test that an unrelated route's handler still runs to completion after its client disconnects alongside Application.mcp.

@devcrocod
devcrocod requested a review from e5l October 13, 2026 21:10

@e5l e5l left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

last air comment is valid, lgtm except it

Copilot AI balanced review requested due to automatic review settings October 15, 2026 17:52
`intercept` inside `route(path)` resolved to `Application.intercept` and answered every GET in the app, `/health` included.
@devcrocod
devcrocod force-pushed the devcrocod/streamable-http-session-limits branch from 0791080 to f27933f Compare October 15, 2026 17:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Cleanup and activity-timestamp races can leave requests pending or expire sessions prematurely.

3 open findings

🧠 Review effort: Balanced

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Glad to approve this one — no blocking findings from my side.


Produced by Air Automations. Name: Code review / Run: https://air.jetbrains.cloud/org/05cf1a7f-6ab5-713b-abd3-29d0c8a05e2d/automations/409567ec-d3a7-40ac-ba95-8518ec577ac2?run=0b0d3d28-8cff-4c07-a845-d2c2fb782eff

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work — the session bounds and stream teardown look solid, and the earlier scoping problems are resolved, so this is good to go from my side.

@devcrocod
devcrocod requested a review from e5l October 15, 2026 21:50

@e5l e5l left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants