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

View file

@ -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.
<!--- CLEAR -->
```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<String>) {
val port = args.firstOrNull()?.toIntOrNull() ?: 3000
val mcpServer = Server(
@ -231,11 +226,8 @@ fun main(args: Array<String>) {
) { 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:
<!--- CLEAR -->
<!--- INCLUDE
@ -809,11 +803,30 @@ embeddedServer(CIO, port = 3000) {
-->
<!--- KNIT example-server-routes-01.kt -->
**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: