Skip to content

feat: SSE heartbeat#761

Open
UnscientificJsZhai wants to merge 2 commits into
modelcontextprotocol:mainfrom
UnscientificJsZhai:feature/sse_heartbeat
Open

feat: SSE heartbeat#761
UnscientificJsZhai wants to merge 2 commits into
modelcontextprotocol:mainfrom
UnscientificJsZhai:feature/sse_heartbeat

Conversation

@UnscientificJsZhai

@UnscientificJsZhai UnscientificJsZhai commented May 11, 2026

Copy link
Copy Markdown

Summary

Adds optional SSE heartbeat configuration for streamable HTTP MCP server connections.

This lets Application.mcpStreamableHttp callers pass Ktor's Heartbeat configuration for SSE streams, including heartbeat period and event payload. Heartbeats remain disabled by default, preserving the existing stream behavior unless the new option is explicitly provided.

Motivation and Context

Some MCP clients need heartbeat messages on SSE connections to keep the connection alive. Without those heartbeats, the client may proactively disconnect from the MCP server. MCP server developers need a way to configure an SSE heartbeat mechanism so they can avoid unexpected client disconnects.

In my case, my MCP server was consistently disconnected by Gemini CLI. I worked around the issue with some fallback approaches, but I believe the best implementation is to add heartbeat support by using Ktor's built-in API.

Implementation Notes

  • Added a function overload of Application.mcpStreamableHttp to introduce heartbeat parameter sseHeartbeatConfig: (Heartbeat.() -> Unit)?.
  • Applied the configuration inside the Ktor sse { ... } block before handling the existing streamable HTTP transport request.
  • Updated the server API dump for the new public API surface.

How Has This Been Tested?

Added StreamableHttpHeartbeatIntegrationTest covering:

  • Configured GET SSE streams apply the provided heartbeat configuration.
  • GET SSE streams do not send default heartbeats when sseHeartbeatConfig is omitted.
  • Configured GET SSE streams emit heartbeat events repeatedly.
  • Legacy MCP over SSE APIs do not expose the new heartbeat configuration.

Verified locally with:

./gradlew :integration-test:jvmTest --tests "io.modelcontextprotocol.kotlin.sdk.integration.streamablehttp.StreamableHttpHeartbeatIntegrationTest"

Result: BUILD SUCCESSFUL.

Breaking Changes

None.

This change adds a new Application.mcpStreamableHttp overload that accepts
sseHeartbeatConfig: (Heartbeat.() -> Unit)? while keeping the existing overload
unchanged. Existing callers continue to compile and keep the previous behavior:
SSE heartbeats are not sent unless the new overload is used with an explicit
heartbeat configuration.

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

Additional context

Heartbeat behavior is delegated to Ktor's SSE heartbeat support, so applications can use the same configuration semantics they would use in native Ktor SSE routes.

Related issue: SSE needs a heartbeat #344

@devcrocod devcrocod force-pushed the feature/sse_heartbeat branch from 29365d6 to e52f4ad Compare June 29, 2026 10:40

@devcrocod devcrocod 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.

This PR adds the ktor api to the transport layer, which we would like to avoid since we plan to move away from a tight coupling with ktor.
Please consider using heartbeat only in the ktor dsl

@UnscientificJsZhai

Copy link
Copy Markdown
Author

This PR adds the ktor api to the transport layer, which we would like to avoid since we plan to move away from a tight coupling with ktor. Please consider using heartbeat only in the ktor dsl

The problem is that we can't access io.ktor.server.sse.sse block from outside io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp block.

KtorServer.kt is a file contains a lot of Ktor API endpoints, It is already tightly coupled with Ktor.

Maybe I should remove the reference of io.ktor.server.sse.Heartbeat from StreamableHttpServerTransport.kt‎ and use the parameters we defined? However, this would limit the user's ability to control the SSE heartbeat.

@devcrocod

Copy link
Copy Markdown
Contributor

The problem is that we can't access io.ktor.server.sse.sse block from outside io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp block.

Maybe I'm missing something, but couldn't we just pass an additional parameter and use the heartbeat in serverSSESession? That way, we wouldn't have to expose the ktor api in core.

@UnscientificJsZhai

UnscientificJsZhai commented Jul 3, 2026

Copy link
Copy Markdown
Author

The problem is that we can't access io.ktor.server.sse.sse block from outside io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp block.

Maybe I'm missing something, but couldn't we just pass an additional parameter and use the heartbeat in serverSSESession? That way, we wouldn't have to expose the ktor api in core.

I overthought this. I initially thought I needed to extract the mandatory SSE heartbeat parameters (like the interval) into a new configuration class to avoid a direct dependency on Ktor's config.

However, since KtorServer.kt is already tightly coupled with Ktor anyway, it makes much more sense to handle everything right there.

@UnscientificJsZhai

Copy link
Copy Markdown
Author

To ensure binary compatibility for the API, I opted to add a function overload to introduce this parameter.

@KtorDsl
public fun Application.mcpStreamableHttp(
    path: String = "/mcp",
    enableDnsRebindingProtection: Boolean = true,
    allowedHosts: List<String>? = null,
    allowedOrigins: List<String>? = null,
    eventStore: EventStore? = null,
    sseHeartbeatConfig: (Heartbeat.() -> Unit)?,
    block: RoutingContext.() -> Server,
) { /* ... */ }

I plan to mark the old function as deprecated later and set a default value null for the new parameter in the new function.

Please let me know if a breaking change like this to the API would actually be preferable in this scenario:

@KtorDsl
public fun Application.mcpStreamableHttp(
    path: String = "/mcp",
    enableDnsRebindingProtection: Boolean = true,
    allowedHosts: List<String>? = null,
    allowedOrigins: List<String>? = null,
    eventStore: EventStore? = null,
    sseHeartbeatConfig: (Heartbeat.() -> Unit)? = null,
    block: RoutingContext.() -> Server,
) { /* ... */ }

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants