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:
parent
e21d2298a1
commit
3617ce4e15
6 changed files with 267 additions and 61 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
15
kotlin-sdk-server/src/jvmTest/resources/logback-test.xml
Normal file
15
kotlin-sdk-server/src/jvmTest/resources/logback-test.xml
Normal 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>
|
||||
|
|
@ -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
|
||||
Loading…
Reference in a new issue