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:
parent
7474a33c56
commit
f434fcaff1
3 changed files with 1553 additions and 135 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue