feat!: SEP-1577 sampling with tools (#718)

Implements SEP-1577 sampling With tools

closes #409 

## How Has This Been Tested?
unit + integration 

## Breaking Changes
- binary changes
- MediaContent -> SamplingMessageContent
- 

## Types of changes
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] 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:
Pavel Gorgulov 2026-04-28 17:51:48 +02:00 • committed by GitHub
commit f434fcaff1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1553 additions and 135 deletions

View file

@ -11,6 +11,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestURLParams
import io.modelcontextprotocol.kotlin.sdk.types.ElicitResult
import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotification
import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult
import io.modelcontextprotocol.kotlin.sdk.types.IncludeContext
import io.modelcontextprotocol.kotlin.sdk.types.ListRootsRequest
import io.modelcontextprotocol.kotlin.sdk.types.ListRootsResult
import io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel
@ -200,13 +201,34 @@ internal class ClientConnectionImpl(private val session: ServerSession) : Client
}
override suspend fun createMessage(request: CreateMessageRequest, options: RequestOptions?): CreateMessageResult {
with(request.params) {
logger.debug {
"Creating message with ${messages.size} messages, maxTokens=$maxTokens, " +
"temperature=$temperature, " +
"systemPrompt=${if (systemPrompt != null) "present" else "absent"}"
val caps = session.clientCapabilities
val params = request.params
if (params.tools != null || params.toolChoice != null) {
requireNotNull(caps?.sampling?.tools) {
"Client did not advertise sampling.tools capability; cannot send " +
"tools/toolChoice in sampling/createMessage request."
}
}
if (params.includeContext != null && params.includeContext != IncludeContext.None) {
if (caps?.sampling?.context == null) {
logger.warn {
"Client did not advertise sampling.context capability but server requested " +
"includeContext=${params.includeContext}. This is soft-deprecated and may be " +
"rejected by future spec versions."
}
}
}
validateSamplingMessages(params.messages)
logger.debug {
"Creating message with ${params.messages.size} messages, maxTokens=${params.maxTokens}, " +
"temperature=${params.temperature}, " +
"systemPrompt=${if (params.systemPrompt != null) "present" else "absent"}, " +
"tools=${params.tools?.size ?: 0}, toolChoice=${params.toolChoice?.mode}"
}
logger.trace { "Full createMessage params: $request" }
return request(request, options)
}

View file

@ -0,0 +1,51 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.modelcontextprotocol.kotlin.sdk.types.SamplingMessage
import io.modelcontextprotocol.kotlin.sdk.types.ToolResultContent
import io.modelcontextprotocol.kotlin.sdk.types.ToolUseContent
/**
* Validates tool_use / tool_result rules on the last two messages of [messages].
*
* Only the boundary between the previous message and the final message matters, because
* earlier turns were already validated when they were appended; the sole freshly-built
* portion of a sampling request is its tail.
*
* Rules enforced:
*
* 1. If the last message contains any `tool_result` block, it MUST contain only
* `tool_result` blocks (no mixing with text/image/audio/tool_use).
* 2. If the last message contains any `tool_result`, the previous message MUST contain
* matching `tool_use` blocks.
* 3. If the previous message contains `tool_use` blocks, the last message's
* `tool_result` ids MUST form exactly the same set.
*
* On the first violation throws [IllegalArgumentException]. No-op when there are fewer
* than two messages or no tool_use / tool_result blocks are involved.
*/
internal fun validateSamplingMessages(messages: List<SamplingMessage>) {
if (messages.isEmpty()) return
val last = messages.last().content
val hasToolResult = last.any { it is ToolResultContent }
val previous = messages.getOrNull(messages.size - 2)?.content.orEmpty()
val hasPreviousToolUse = previous.any { it is ToolUseContent }
if (hasToolResult) {
require(last.all { it is ToolResultContent }) {
"The last message must contain only tool_result content if any is present"
}
require(hasPreviousToolUse) {
"tool_result blocks are not matching any tool_use from the previous message"
}
}
if (hasPreviousToolUse) {
val toolUseIds = previous.filterIsInstance<ToolUseContent>().map { it.id }.toSet()
val toolResultIds = last.filterIsInstance<ToolResultContent>().map { it.toolUseId }.toSet()
require(toolUseIds == toolResultIds) {
"ids of tool_result blocks and tool_use blocks from previous message do not match"
}
}
}

View file

@ -0,0 +1,86 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.modelcontextprotocol.kotlin.sdk.types.Role
import io.modelcontextprotocol.kotlin.sdk.types.SamplingMessage
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import io.modelcontextprotocol.kotlin.sdk.types.ToolResultContent
import io.modelcontextprotocol.kotlin.sdk.types.ToolUseContent
import kotlinx.serialization.json.JsonObject
import org.junit.jupiter.api.assertDoesNotThrow
import kotlin.test.Test
import kotlin.test.assertFailsWith
/**
* Unit tests for [validateSamplingMessages]: pure-function checks of the SEP-1577
* last-two-messages tool_use / tool_result rules.
*
* End-to-end and capability-enforcement tests for `Server.createMessage` live in the
* `integration-test` module under the same package.
*/
class SamplingTest {
private fun toolUse(id: String) = ToolUseContent(id = id, name = "t", input = JsonObject(emptyMap()))
private fun toolResult(id: String) = ToolResultContent(toolUseId = id, content = emptyList())
@Test
fun `validate empty message list is valid`() {
assertDoesNotThrow { validateSamplingMessages(emptyList()) }
}
@Test
fun `validate text-only conversation is valid`() {
assertDoesNotThrow {
validateSamplingMessages(
listOf(
SamplingMessage(Role.User, TextContent("hi")),
SamplingMessage(Role.Assistant, TextContent("hello")),
),
)
}
}
@Test
fun `validate matched tool_use and tool_result at boundary is valid`() {
assertDoesNotThrow {
validateSamplingMessages(
listOf(
SamplingMessage(Role.Assistant, listOf(TextContent("using tool"), toolUse("c1"))),
SamplingMessage(Role.User, toolResult("c1")),
),
)
}
}
@Test
fun `validate orphan tool_result with no previous message fails`() {
assertFailsWith<IllegalArgumentException> {
validateSamplingMessages(
listOf(SamplingMessage(Role.User, toolResult("missing"))),
)
}
}
@Test
fun `validate tool_result mixed with text in last message fails`() {
assertFailsWith<IllegalArgumentException> {
validateSamplingMessages(
listOf(
SamplingMessage(Role.Assistant, toolUse("c1")),
SamplingMessage(Role.User, listOf(toolResult("c1"), TextContent("extra"))),
),
)
}
}
@Test
fun `validate tool_result ids must match tool_use ids in previous message`() {
assertFailsWith<IllegalArgumentException> {
validateSamplingMessages(
listOf(
SamplingMessage(Role.Assistant, toolUse("c1")),
SamplingMessage(Role.User, toolResult("wrong_id")),
),
)
}
}
}