Skip to content

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

Merged
kpavlov merged 6 commits intomainfrom
kpavlov/664-json-config
Apr 2, 2026
Merged

feat(server): auto-install ContentNegotiation with McpJson (#664)#665
kpavlov merged 6 commits intomainfrom
kpavlov/664-json-config

Conversation

@kpavlov
Copy link
Copy Markdown
Contributor

@kpavlov kpavlov commented Apr 1, 2026

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 featuremcp(), 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

  • 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

@kpavlov kpavlov added documentation Improvements or additions to documentation bugfix internal-users Raised by internal users labels Apr 1, 2026
@kpavlov kpavlov force-pushed the kpavlov/664-json-config branch from 38b5ee0 to 29c5946 Compare April 1, 2026 14:11
kpavlov added 4 commits April 1, 2026 17:31
mcp(), mcpStreamableHttp(), and mcpStatelessStreamableHttp() now
install ContentNegotiation configured with McpJson automatically,
removing the need for users to do it manually. If ContentNegotiation was already installed by user code, a warning is logged instead of
crashing with DuplicatePluginException.
- adjust formatting in StreamableHttpClientTest for consistency
- ci: run ktlint first and fail fast on CI
- Replaced captureStderr with a Logback ListAppender-based LogCapture utility
- Swapped slf4j-simple for logback-classic in kotlin-sdk-server's test dependencies
- Added logback-test.xml (matching the previous simplelogger.properties levels)
- Tests now assert directly on captured log events instead of relying on stderr output
@kpavlov kpavlov force-pushed the kpavlov/664-json-config branch from 27849ca to 787211b Compare April 1, 2026 14:31
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 1, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

@kpavlov kpavlov marked this pull request as ready for review April 1, 2026 15:17
@kpavlov kpavlov requested review from devcrocod and e5l and removed request for e5l April 1, 2026 15:17
Copy link
Copy Markdown
Contributor

@devcrocod devcrocod left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the PR, this indeed needs to be fixed

However, it is still unclear to me what the user is supposed to do if they want to provide their own JSON configuration
for example, enable pretty printing or set other options in addition to explicitNulls and the rest

Also, in addition to the comments I already left, the AI review pointed out that KotlinTestBase was missed as well, and it also uses install(ContentNegotiation)

- os: ubuntu-latest
job-name: "Linux Tests"
gradle-tasks: "build"
gradle-tasks: "ktlintCheck build"
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.

How is it related to PR?
and ktlintCheck runs with check, which means it's already included in build task

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

build runs check after running tests. Build should fail earlier if it can not be merged.

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.

build runs check after running tests. Build should fail earlier if it can not be merged.

That's not correct. test is part of check

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.

ktlintCheck doesn't depend on the tests. When running build, the tests and ktlintCheck are executed in parallel. So it is not entirely clear in which scenario "Build should fail earlier" would actually make the build fail faster

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" }
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.

Why do we need another logger?

Copy link
Copy Markdown
Contributor Author

@kpavlov kpavlov Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For testing, please check the tests.

Comment on lines +30 to +31
"Remove your install(ContentNegotiation) { … } block and let " +
"mcp() / mcpStreamableHttp() / mcpStatelessStreamableHttp() configure it automatically."
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.

To be honest, that’s a strange suggestion for me

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Softened

Comment on lines +89 to +90
* Automatically installs [ContentNegotiation][io.ktor.server.plugins.contentnegotiation.ContentNegotiation]
* with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson] and [SSE].
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.

Why hasn’t kdoc been updated for the other methods?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

configuration: StreamableHttpServerTransport.Configuration,
block: RoutingContext.() -> Server,
) {
installMcpContentNegotiation()
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.

If the user is configured to use multiple extensions, they will see a warning saying that ContentNegotiation is already installed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly

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.

?
I mean our extensions: mcp and mcpStreamable

Copy link
Copy Markdown
Contributor Author

@kpavlov kpavlov Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, we must store an application attribute and make installMcpContentNegotiation() completely idempotent. Done

Comment on lines +19 to +35
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()
}
}
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.

Wouldn't it be enough to just verify that no DuplicatePluginException is thrown?
The existing integration tests already prove that this works, since they did not break after the manual installation of ContentNegotiation was removed

Verifying the exact log output does not seem quite right because it may change at any moment. On top of that, it adds another dependency, extra configuration, and new tests that verify log output. It feels unnecessary

Copy link
Copy Markdown
Contributor Author

@kpavlov kpavlov Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DuplicatePluginException should not be thrown, otherwise it will break current users. Also, users should still be able to install their own ContentNegotiation configuration. That's why it is warniong, not an error.

The log message that should be verified, this is expected behavior and to verify. When extra dependency and configuration is needed - it is needed

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.

It's the log message that should be verified. When extra dependency and configuration is needed - it is needed

Why is that? Your comment doesn’t make it clear

Comment on lines +809 to +826
**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")
}
```

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.

How is it related to PR?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a finding from the troubleshooting session. The MCP Inspector does not function with the default configuration without this.

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.

Didn't you suggest not mixing unrelated changes in the same PR?

Copy link
Copy Markdown
Contributor Author

@kpavlov kpavlov Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, but this often occurs in this project. This note is related to the user’s issue. So "boy scout's rule" apply.

README.md Outdated
Comment on lines +231 to +232
// mcpStreamableHttp() automatically installs ContentNegotiation with McpJson.
// Do NOT install ContentNegotiation yourself — the SDK handles it.
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.

Why need this comment in the code snippet?

Use Application.attributes to track whether the SDK has already handled
ContentNegotiation installation. Subsequent calls return immediately
without re-installing or logging spurious warnings.

Also softens the warning message for user-pre-installed ContentNegotiation
to suggest ensuring json(McpJson) is included rather than demanding removal,
and adds auto-install KDoc to mcpStreamableHttp/mcpStatelessStreamableHttp.

Remove comment in code snippet in README.md
Copy link
Copy Markdown
Contributor

@e5l e5l left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues found — see inline comments.

"Ensure your ContentNegotiation configuration includes json(McpJson)."
}
} else {
install(ContentNegotiation) {
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.

When the user has pre-installed ContentNegotiation with wrong config (e.g. default json() instead of json(McpJson)), this logs a warning but doesn't actually fix anything — the runtime serialization failures still happen silently.

This means the most dangerous case (user installed CN incorrectly) isn't addressed. Consider either:

  • Registering McpJson as an additional converter even when CN is already installed
  • Failing fast with an exception instead of a warning

As written, the fix only helps users who forgot to install CN entirely, not those who installed it with the wrong config.

api(libs.ktor.server.core)
api(libs.ktor.server.sse)
implementation(libs.ktor.server.content.negotiation)
implementation(libs.ktor.server.websockets)
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.

ktor-serialization should already come in transitively via ktor-server-content-negotiation. Is there a specific multiplatform reason this is needed as a direct dependency? If not, it can be removed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, both ktor-server-content-negotiation and ktor-serialization are required

@e5l e5l self-requested a review April 2, 2026 08:00
Copy link
Copy Markdown
Contributor

@e5l e5l left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed to fix it as separate pr

@kpavlov kpavlov merged commit 3617ce4 into main Apr 2, 2026
20 checks passed
@kpavlov kpavlov deleted the kpavlov/664-json-config branch April 2, 2026 08:01
@kpavlov
Copy link
Copy Markdown
Contributor Author

kpavlov commented Apr 2, 2026

Added follow-up issue #668

@kpavlov kpavlov added this to the 0.11 milestone Apr 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation internal-users Raised by internal users

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Server silently accepts wrong ContentNegotiation JSON configuration, causing runtime serialization failures

4 participants