From 3617ce4e158fa61c1f24733036bab0f624f6c3f1 Mon Sep 17 00:00:00 2001 From: Konstantin Pavlov <1517853+kpavlov@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:01:13 +0300 Subject: [PATCH] feat(server): auto-install ContentNegotiation with McpJson (#664) (#665) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/build.yml | 2 +- README.md | 87 ++++++++------- .../sdk/conformance/ConformanceServer.kt | 7 -- gradle/libs.versions.toml | 2 + .../AbstractStreamableHttpIntegrationTest.kt | 7 -- kotlin-sdk-server/build.gradle.kts | 4 +- .../kotlin/sdk/server/KtorServer.kt | 12 ++ .../kotlin/sdk/server/KtorServerHelpers.kt | 46 ++++++++ .../kotlin/sdk/server/LogCapture.kt | 35 ++++++ .../StreamableHttpServerConfigurationTest.kt | 103 ++++++++++++++++++ .../src/jvmTest/resources/logback-test.xml | 15 +++ .../jvmTest/resources/simplelogger.properties | 8 -- 12 files changed, 267 insertions(+), 61 deletions(-) create mode 100644 kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServerHelpers.kt create mode 100644 kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/LogCapture.kt create mode 100644 kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerConfigurationTest.kt create mode 100644 kotlin-sdk-server/src/jvmTest/resources/logback-test.xml delete mode 100644 kotlin-sdk-server/src/jvmTest/resources/simplelogger.properties diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3467db2..08242f7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,7 +34,7 @@ jobs: include: - os: ubuntu-latest job-name: "Linux Tests" - gradle-tasks: "build" + gradle-tasks: "ktlintCheck build" max-workers: 3 - os: macos-latest diff --git a/README.md b/README.md index 3e65b26..58a2cad 100644 --- a/README.md +++ b/README.md @@ -19,34 +19,34 @@ standardized protocol interface. * [Overview](#overview) * [Installation](#installation) - * [Artifacts](#artifacts) - * [Gradle setup (JVM)](#gradle-setup-jvm) - * [Multiplatform](#multiplatform) - * [Ktor dependencies](#ktor-dependencies) + * [Artifacts](#artifacts) + * [Gradle setup (JVM)](#gradle-setup-jvm) + * [Multiplatform](#multiplatform) + * [Ktor dependencies](#ktor-dependencies) * [Quickstart](#quickstart) - * [Creating a Client](#creating-a-client) - * [Creating a Server](#creating-a-server) + * [Creating a Client](#creating-a-client) + * [Creating a Server](#creating-a-server) * [Core Concepts](#core-concepts) - * [MCP Primitives](#mcp-primitives) - * [Capabilities](#capabilities) - * [Server Capabilities](#server-capabilities) - * [Client Capabilities](#client-capabilities) - * [Server Features](#server-features) - * [Prompts](#prompts) - * [Resources](#resources) - * [Tools](#tools) - * [Completion](#completion) - * [Logging](#logging) - * [Pagination](#pagination) - * [Client Features](#client-features) - * [Roots](#roots) - * [Sampling](#sampling) + * [MCP Primitives](#mcp-primitives) + * [Capabilities](#capabilities) + * [Server Capabilities](#server-capabilities) + * [Client Capabilities](#client-capabilities) + * [Server Features](#server-features) + * [Prompts](#prompts) + * [Resources](#resources) + * [Tools](#tools) + * [Completion](#completion) + * [Logging](#logging) + * [Pagination](#pagination) + * [Client Features](#client-features) + * [Roots](#roots) + * [Sampling](#sampling) * [Transports](#transports) - * [STDIO Transport](#stdio-transport) - * [Streamable HTTP Transport](#streamable-http-transport) - * [SSE Transport](#sse-transport) - * [WebSocket Transport](#websocket-transport) - * [ChannelTransport (testing)](#channeltransport-testing) + * [STDIO Transport](#stdio-transport) + * [Streamable HTTP Transport](#streamable-http-transport) + * [SSE Transport](#sse-transport) + * [WebSocket Transport](#websocket-transport) + * [ChannelTransport (testing)](#channeltransport-testing) * [Connecting your server](#connecting-your-server) * [Examples](#examples) * [Documentation](#documentation) @@ -188,24 +188,19 @@ the [simple-streamable-server](samples/simple-streamable-server) sample. ```kotlin -import io.ktor.serialization.kotlinx.json.json -import io.ktor.server.application.install import io.ktor.server.cio.CIO import io.ktor.server.engine.embeddedServer -import io.ktor.server.plugins.contentnegotiation.ContentNegotiation import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.McpJson import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.TextContent import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put - fun main(args: Array) { val port = args.firstOrNull()?.toIntOrNull() ?: 3000 val mcpServer = Server( @@ -231,11 +226,8 @@ fun main(args: Array) { ) { request -> CallToolResult(content = listOf(TextContent("Hello, world!"))) } - + embeddedServer(CIO, host = "127.0.0.1", port = port) { - install(ContentNegotiation) { - json(McpJson) - } mcpStreamableHttp { mcpServer } @@ -774,8 +766,10 @@ CLI tooling that spawns a helper process. No networking setup is required. `StreamableHttpClientTransport` and the Ktor `mcpStreamableHttp()` / `mcpStatelessStreamableHttp()` helpers expose MCP over a single HTTP endpoint with optional JSON-only or SSE streaming responses. This is the recommended choice for -remote deployments and integrates nicely with proxies or service meshes. Both accept a `path` parameter (default: -`"/mcp"`) to mount the endpoint at any URL: +remote deployments and integrates nicely with proxies or service meshes. + +These helpers automatically install `ContentNegotiation` with `McpJson` — do not install it yourself, or a warning will +be logged. Both accept a `path` parameter (default: `"/mcp"`) to mount the endpoint at any URL: +**CORS for browser-based clients (e.g. MCP Inspector):** if you connect from a browser-based +client you need to install the Ktor CORS plugin so that MCP-specific headers are allowed and exposed: + +```kotlin +install(CORS) { + anyHost() // restrict to specific origins in production + allowMethod(HttpMethod.Options) + allowMethod(HttpMethod.Get) + allowMethod(HttpMethod.Post) + allowMethod(HttpMethod.Delete) + allowNonSimpleContentTypes = true + allowHeader("Mcp-Session-Id") + allowHeader("Mcp-Protocol-Version") + exposeHeader("Mcp-Session-Id") + exposeHeader("Mcp-Protocol-Version") +} +``` + ### SSE Transport Server-Sent Events remain available for backwards compatibility with older MCP clients. Two Ktor helpers are provided: -- **`Application.mcp { }`** — installs the SSE plugin automatically and registers MCP endpoints at `/`. +- **`Application.mcp { }`** — installs SSE and `ContentNegotiation` with `McpJson` automatically, then registers MCP + endpoints at `/`. Do not install `ContentNegotiation` yourself — the SDK handles it. - **`Route.mcp { }`** — registers MCP endpoints at the current route path; requires `install(SSE)` in the application first. Use this to host MCP alongside other routes or under a path prefix: diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceServer.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceServer.kt index b420775..03d5ee3 100644 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceServer.kt +++ b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceServer.kt @@ -1,23 +1,16 @@ package io.modelcontextprotocol.kotlin.sdk.conformance -import io.ktor.serialization.kotlinx.json.json -import io.ktor.server.application.install import io.ktor.server.cio.CIO import io.ktor.server.engine.embeddedServer -import io.ktor.server.plugins.contentnegotiation.ContentNegotiation import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.McpJson import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities fun main() { val port = System.getenv("MCP_PORT")?.toIntOrNull() ?: 3001 embeddedServer(CIO, port = port) { - install(ContentNegotiation) { - json(McpJson) - } mcpStreamableHttp( enableDnsRebindingProtection = true, allowedHosts = listOf("localhost", "127.0.0.1", "localhost:$port", "127.0.0.1:$port"), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f511d0d..78f55fe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ mockk = "1.14.9" mokksy = "0.8.1" serialization = "1.10.0" slf4j = "2.0.17" +logback = "1.5.32" junit = "5.14.3" [libraries] @@ -67,6 +68,7 @@ mockk = { module = "io.mockk:mockk", version.ref = "mockk" } mokksy = { group = "dev.mokksy", name = "mokksy", version.ref = "mokksy" } netty-bom = { group = "io.netty", name = "netty-bom", version.ref = "netty" } slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" } +logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" } junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" } # Samples diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/AbstractStreamableHttpIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/AbstractStreamableHttpIntegrationTest.kt index 0f6646e..9bc0c17 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/AbstractStreamableHttpIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/AbstractStreamableHttpIntegrationTest.kt @@ -1,17 +1,13 @@ package io.modelcontextprotocol.kotlin.sdk.integration.streamablehttp -import io.ktor.serialization.kotlinx.json.json -import io.ktor.server.application.install import io.ktor.server.cio.CIOApplicationEngine import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.embeddedServer -import io.ktor.server.plugins.contentnegotiation.ContentNegotiation import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.McpJson import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument import io.modelcontextprotocol.kotlin.sdk.types.PromptMessage import io.modelcontextprotocol.kotlin.sdk.types.Role @@ -56,9 +52,6 @@ open class AbstractStreamableHttpIntegrationTest { host = URL, port = PORT, ) { - install(ContentNegotiation) { - json(McpJson) - } mcpStreamableHttp { mcpServer } } diff --git a/kotlin-sdk-server/build.gradle.kts b/kotlin-sdk-server/build.gradle.kts index 3a0a398..126c516 100644 --- a/kotlin-sdk-server/build.gradle.kts +++ b/kotlin-sdk-server/build.gradle.kts @@ -12,7 +12,9 @@ kotlin { api(project(":kotlin-sdk-core")) api(libs.ktor.server.core) api(libs.ktor.server.sse) + implementation(libs.ktor.server.content.negotiation) implementation(libs.ktor.server.websockets) + implementation(libs.ktor.serialization) implementation(libs.kotlin.logging) } } @@ -37,7 +39,7 @@ kotlin { implementation(libs.ktor.server.content.negotiation) implementation(libs.ktor.server.test.host) - runtimeOnly(libs.slf4j.simple) + implementation(libs.logback.classic) } } } diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServer.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServer.kt index 739f8d3..35dcc7e 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServer.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServer.kt @@ -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() @@ -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. diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServerHelpers.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServerHelpers.kt new file mode 100644 index 0000000..42093c8 --- /dev/null +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServerHelpers.kt @@ -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("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) + } + } +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/LogCapture.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/LogCapture.kt new file mode 100644 index 0000000..dcf3ad2 --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/LogCapture.kt @@ -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 = ListAppender() + + init { + appender.start() + logger.addAppender(appender) + } + + val messages: List + get() = appender.list.map { it.formattedMessage } + + override fun close() { + logger.detachAppender(appender) + appender.stop() + } +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerConfigurationTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerConfigurationTest.kt new file mode 100644 index 0000000..1efe2ab --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerConfigurationTest.kt @@ -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) + } + } +} diff --git a/kotlin-sdk-server/src/jvmTest/resources/logback-test.xml b/kotlin-sdk-server/src/jvmTest/resources/logback-test.xml new file mode 100644 index 0000000..aa33478 --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/resources/logback-test.xml @@ -0,0 +1,15 @@ + + + System.err + + [%thread] %level %logger - %msg%n + + + + + + + + + + diff --git a/kotlin-sdk-server/src/jvmTest/resources/simplelogger.properties b/kotlin-sdk-server/src/jvmTest/resources/simplelogger.properties deleted file mode 100644 index 95fb646..0000000 --- a/kotlin-sdk-server/src/jvmTest/resources/simplelogger.properties +++ /dev/null @@ -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