feat(server): auto-install ContentNegotiation with McpJson (#664) (#665)

Users currently must manually `install(ContentNegotiation) {
json(McpJson) }` before calling `mcp()`,
`mcpStreamableHttp()`, or `mcpStatelessStreamableHttp()`. Forgetting
this or using the default `json()`
instead of `json(McpJson)` causes silent serialization failures at
runtime (e.g., explicit null fields in JSON-RPC
responses). This PR makes the SDK install it automatically.

Fixes #664

## Changes

- **Core feature** — `mcp()`, `mcpStreamableHttp()`, and
`mcpStatelessStreamableHttp()` now auto-install `ContentNegotiation`
configured with `McpJson`. If the user already installed
`ContentNegotiation`, a warning is logged instead of crashing with
`DuplicatePluginException`.

- **`KtorServerHelpers.kt`** (new) — `installMcpContentNegotiation()`
internal helper with `pluginOrNull` guard and warning
- **`KtorServer.kt`** — calls the helper from all three entry points
- **`build.gradle.kts`** — adds `ktor-server-content-negotiation` and
`ktor-serialization` as `implementation` deps to `kotlin-sdk-server`

- **Removed manual installs** from `ConformanceServer.kt` and
`AbstractStreamableHttpIntegrationTest.kt`.

- **Test infrastructure** — replaced `slf4j-simple` with Logback and use
`ListAppender` (`LogCapture` utility) for deterministic log assertion.
Added `logback-test.xml` matching previous log levels.

- **`StreamableHttpServerConfigurationTest.kt`** (new) — verifies the
warning for all three entry points: `mcpStreamableHttp`,
`mcpStatelessStreamableHttp`, and `mcp` (SSE).

- **README.md** — removed manual `ContentNegotiation` boilerplate from
examples, documented automatic installation, added CORS configuration
snippet for browser-based clients.

- **CI** — added `ktlintCheck` to the Linux build matrix job.


## How Has This Been Tested?

Unit/integration tests. Conformance tests updated

## Breaking Changes

No

## Types of changes
- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Documentation update

## Checklist

- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [x] My code follows the repository's style guidelines
- [x] New and existing tests pass locally
- [x] I have added appropriate error handling
- [x] I have added or updated documentation as needed
This commit is contained in:
Konstantin Pavlov 2026-04-02 11:01:13 +03:00 • committed by GitHub
commit 3617ce4e15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 267 additions and 61 deletions

View file

@ -86,11 +86,15 @@ public fun Route.mcp(block: ServerSSESession.() -> Server) {
* over [Server-Sent Events (SSE) Transport](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse)
* and sets up routing with the provided configuration block.
*
* Automatically installs [ContentNegotiation][io.ktor.server.plugins.contentnegotiation.ContentNegotiation]
* with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson] and [SSE].
*
* @param block factory block with access to the [ServerSSESession]
* that creates and returns the [Server] to handle the connection.
*/
@KtorDsl
public fun Application.mcp(block: ServerSSESession.() -> Server) {
installMcpContentNegotiation()
install(SSE)
routing {
@ -103,6 +107,7 @@ private fun Application.mcpStreamableHttp(
configuration: StreamableHttpServerTransport.Configuration,
block: RoutingContext.() -> Server,
) {
installMcpContentNegotiation()
install(SSE)
val transportManager = TransportManager<StreamableHttpServerTransport>()
@ -139,6 +144,9 @@ private fun Application.mcpStreamableHttp(
* Sets up SSE, HTTP POST, and DELETE endpoints at the specified [path].
* Simple request/response pairs are returned as JSON (not SSE streams).
*
* Automatically installs [ContentNegotiation][io.ktor.server.plugins.contentnegotiation.ContentNegotiation]
* with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson] and [SSE].
*
* @param path The base path for the MCP Streamable HTTP endpoint. Defaults to "/mcp".
* @param enableDnsRebindingProtection Enables DNS rebinding attack protection for the endpoint. Defaults to false.
* @param allowedHosts A list of hostnames allowed to access the endpoint. If `null`, no restrictions are applied.
@ -177,6 +185,7 @@ private fun Application.mcpStatelessStreamableHttp(
configuration: StreamableHttpServerTransport.Configuration,
block: RoutingContext.() -> Server,
) {
installMcpContentNegotiation()
install(SSE)
routing {
@ -212,6 +221,9 @@ private fun Application.mcpStatelessStreamableHttp(
* Sets up an HTTP POST endpoint at [path]. GET and DELETE requests return 405 Method Not Allowed.
* Simple request/response pairs are returned as JSON (not SSE streams).
*
* Automatically installs [ContentNegotiation][io.ktor.server.plugins.contentnegotiation.ContentNegotiation]
* with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson] and [SSE].
*
* @param path The URL path where the server listens for incoming JSON-RPC requests. Defaults to "/mcp".
* @param enableDnsRebindingProtection Determines whether DNS rebinding protection is enabled. Defaults to `false`.
* @param allowedHosts A list of allowed hostnames. If null, host filtering is disabled.

View file

@ -0,0 +1,46 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.application.pluginOrNull
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.util.AttributeKey
import io.modelcontextprotocol.kotlin.sdk.types.McpJson
private val logger = KotlinLogging.logger {}
private val McpContentNegotiationInstalled = AttributeKey<Unit>("McpContentNegotiationInstalled")
/**
* Installs [ContentNegotiation] configured with [McpJson].
*
* MCP requires specific JSON settings (`explicitNulls = false`, `encodeDefaults = true`,
* `classDiscriminatorMode = NONE`). Using the default `json()` instead of `json(McpJson)`
* causes serialization failures at runtime (e.g., explicit null fields in JSON-RPC responses).
*
* If [ContentNegotiation] was already installed by user code, a warning is logged because
* Ktor's public API does not expose the Json configuration for verification.
*
* This function is idempotent: subsequent calls on the same [Application] return immediately
* without re-installing the plugin or logging additional warnings.
*/
internal fun Application.installMcpContentNegotiation() {
if (attributes.getOrNull(McpContentNegotiationInstalled) != null) return
attributes.put(McpContentNegotiationInstalled, Unit)
if (pluginOrNull(ContentNegotiation) != null) {
// ContentNegotiation was already installed by user code.
logger.warn {
"ContentNegotiation is already installed. " +
"MCP requires json(McpJson) for correct JSON-RPC serialization " +
"(explicitNulls = false, encodeDefaults = true). " +
"Ensure your ContentNegotiation configuration includes json(McpJson)."
}
} else {
install(ContentNegotiation) {
json(McpJson)
}
}
}

View file

@ -0,0 +1,35 @@
package io.modelcontextprotocol.kotlin.sdk.server
import ch.qos.logback.classic.Logger
import ch.qos.logback.classic.spi.ILoggingEvent
import ch.qos.logback.core.read.ListAppender
import org.slf4j.LoggerFactory
/**
* Captures log events from the given logger (and its children) using a Logback [ListAppender].
*
* Usage:
* ```
* LogCapture("io.modelcontextprotocol.kotlin.sdk.server").use { logs ->
* // … code that emits log messages …
* logs.messages.shouldExist { "expected text" in it }
* }
* ```
*/
internal class LogCapture(loggerName: String) : AutoCloseable {
private val logger: Logger = LoggerFactory.getLogger(loggerName) as Logger
private val appender: ListAppender<ILoggingEvent> = ListAppender()
init {
appender.start()
logger.addAppender(appender)
}
val messages: List<String>
get() = appender.list.map { it.formattedMessage }
override fun close() {
logger.detachAppender(appender)
appender.stop()
}
}

View file

@ -0,0 +1,103 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.kotest.matchers.collections.shouldExist
import io.kotest.matchers.collections.shouldHaveSize
import io.ktor.client.request.get
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.testing.testApplication
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import kotlin.test.Test
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
private const val MCP_SERVER_PACKAGE = "io.modelcontextprotocol.kotlin.sdk.server"
class StreamableHttpServerConfigurationTest {
private fun testServer(): Server = Server(
Implementation("test-server", "1.0"),
ServerOptions(capabilities = ServerCapabilities()),
)
@Test
fun `mcpStreamableHttp warns when ContentNegotiation is pre-installed`() {
LogCapture(MCP_SERVER_PACKAGE).use { logs ->
testApplication {
application {
install(ServerContentNegotiation) {
json() // Pre-install with default (wrong) config
}
mcpStreamableHttp { testServer() }
}
client.get("/mcp")
}
logs.messages.shouldExist { "ContentNegotiation is already installed" in it }
}
}
@Test
fun `mcpStatelessStreamableHttp warns when ContentNegotiation is pre-installed`() {
LogCapture(MCP_SERVER_PACKAGE).use { logs ->
testApplication {
application {
install(ServerContentNegotiation) {
json() // Pre-install with default (wrong) config
}
mcpStatelessStreamableHttp { testServer() }
}
client.get("/mcp")
}
logs.messages.shouldExist { "ContentNegotiation is already installed" in it }
}
}
@Test
fun `mcp warns when ContentNegotiation is pre-installed`() {
LogCapture(MCP_SERVER_PACKAGE).use { logs ->
testApplication {
application {
install(ServerContentNegotiation) {
json() // Pre-install with default (wrong) config
}
mcp { testServer() }
}
client.get("/sse")
}
logs.messages.shouldExist { "ContentNegotiation is already installed" in it }
}
}
@Test
fun `installMcpContentNegotiation is idempotent`() {
LogCapture(MCP_SERVER_PACKAGE).use { logs ->
testApplication {
application {
installMcpContentNegotiation()
installMcpContentNegotiation()
}
client.get("/")
}
logs.messages.filter { "ContentNegotiation is already installed" in it }
.shouldHaveSize(0)
}
}
@Test
fun `installMcpContentNegotiation warns exactly once when ContentNegotiation is pre-installed`() {
LogCapture(MCP_SERVER_PACKAGE).use { logs ->
testApplication {
application {
install(ServerContentNegotiation) {
json()
}
installMcpContentNegotiation()
installMcpContentNegotiation()
}
client.get("/")
}
logs.messages.filter { "ContentNegotiation is already installed" in it }
.shouldHaveSize(1)
}
}
}

View file

@ -0,0 +1,15 @@
<configuration>
<appender name="STDERR" class="ch.qos.logback.core.ConsoleAppender">
<target>System.err</target>
<encoder>
<pattern>[%thread] %level %logger - %msg%n</pattern>
</encoder>
</appender>
<logger name="io.ktor.server" level="DEBUG"/>
<logger name="io.modelcontextprotocol" level="DEBUG"/>
<root level="INFO">
<appender-ref ref="STDERR"/>
</root>
</configuration>

View file

@ -1,8 +0,0 @@
# Level of logging for the ROOT logger: ERROR, WARN, INFO, DEBUG, TRACE (default is INFO)
org.slf4j.simpleLogger.defaultLogLevel=INFO
org.slf4j.simpleLogger.showThreadName=true
org.slf4j.simpleLogger.showDateTime=false
# Log level for specific packages or classes
org.slf4j.simpleLogger.log.io.ktor.server=DEBUG
org.slf4j.simpleLogger.log.io.modelcontextprotocol=DEBUG