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
2 changed files with 1553 additions and 135 deletions

View file

@ -106,7 +106,7 @@ class ClientTest {
),
options = ClientOptions(
capabilities = ClientCapabilities(
sampling = EmptyJsonObject,
sampling = ClientCapabilities.sampling,
),
),
)
@ -152,7 +152,7 @@ class ClientTest {
),
options = ClientOptions(
capabilities = ClientCapabilities(
sampling = EmptyJsonObject,
sampling = ClientCapabilities.sampling,
),
),
)
@ -339,7 +339,7 @@ class ClientTest {
val client = Client(
clientInfo = Implementation(name = "test client", version = "1.0"),
options = ClientOptions(
capabilities = ClientCapabilities(sampling = EmptyJsonObject),
capabilities = ClientCapabilities(sampling = ClientCapabilities.sampling),
),
)
@ -633,18 +633,16 @@ class ClientTest {
),
options = ClientOptions(
capabilities = ClientCapabilities(
sampling = EmptyJsonObject,
sampling = ClientCapabilities.sampling,
),
),
)
client.setRequestHandler<CreateMessageRequest>(Method.Defined.SamplingCreateMessage) { _, _ ->
CreateMessageResult(
model = "test-model",
role = Role.Assistant,
content = TextContent(
text = "Test response",
),
content = TextContent(text = "Test response"),
model = "test-model",
)
}
@ -670,7 +668,7 @@ class ClientTest {
val client = Client(
clientInfo = Implementation(name = "test client", version = "1.0"),
options = ClientOptions(
capabilities = ClientCapabilities(sampling = EmptyJsonObject),
capabilities = ClientCapabilities(sampling = ClientCapabilities.sampling),
),
)

View file

@ -0,0 +1,251 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions
import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport
import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest
import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageResult
import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.IncludeContext
import io.modelcontextprotocol.kotlin.sdk.types.Method
import io.modelcontextprotocol.kotlin.sdk.types.Role
import io.modelcontextprotocol.kotlin.sdk.types.SamplingMessage
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.StopReason
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import io.modelcontextprotocol.kotlin.sdk.types.Tool
import io.modelcontextprotocol.kotlin.sdk.types.ToolChoice
import io.modelcontextprotocol.kotlin.sdk.types.ToolResultContent
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
import io.modelcontextprotocol.kotlin.sdk.types.ToolUseContent
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import org.junit.jupiter.api.assertDoesNotThrow
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class SamplingTest {
private val dummyTool = Tool(
name = "t",
inputSchema = ToolSchema(properties = buildJsonObject { }, required = emptyList()),
)
private val weatherTool = Tool(
name = "get_weather",
description = "Return the current temperature in Celsius.",
inputSchema = ToolSchema(
properties = buildJsonObject {
put("location", buildJsonObject { put("type", JsonPrimitive("string")) })
},
required = listOf("location"),
),
)
private val minimalMessages = listOf(SamplingMessage(Role.User, TextContent("hi")))
/**
* Builds a connected [Server]+[Client] pair using [InMemoryTransport].
*
* @param clientCapabilities the capabilities the client advertises during initialize
* @param samplingHandler the handler the client uses to respond to sampling requests
* @return Pair of (server, sessionId) ready for [Server.createMessage] calls.
*/
private fun buildPair(
clientCapabilities: ClientCapabilities = ClientCapabilities(
sampling = ClientCapabilities.Sampling(),
),
samplingHandler: (CreateMessageRequest) -> CreateMessageResult = { _ ->
CreateMessageResult(role = Role.Assistant, content = TextContent("ok"), model = "m")
},
): Pair<Server, String> {
val server = Server(
serverInfo = Implementation(name = "srv", version = "1.0"),
options = ServerOptions(capabilities = ServerCapabilities()),
)
val client = Client(
clientInfo = Implementation(name = "cli", version = "1.0"),
options = ClientOptions(capabilities = clientCapabilities),
)
client.setRequestHandler<CreateMessageRequest>(Method.Defined.SamplingCreateMessage) { req, _ ->
samplingHandler(req)
}
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
var sessionId: String? = null
runBlocking {
val sessionDeferred = CompletableDeferred<String>()
launch { client.connect(clientTransport) }
launch {
val session = server.createSession(serverTransport)
sessionDeferred.complete(session.sessionId)
}
sessionId = sessionDeferred.await()
}
return Pair(server, checkNotNull(sessionId))
}
// ============================================================================
// Server.createMessage — capability enforcement (SEP-1577)
// ============================================================================
@Test
fun `tools field rejected when client has no sampling tools capability`() {
val (server, sessionId) = buildPair()
assertFailsWith<IllegalArgumentException> {
runBlocking {
server.createMessage(
sessionId = sessionId,
params = CreateMessageRequest(
params = CreateMessageRequestParams(
maxTokens = 100,
messages = minimalMessages,
tools = listOf(dummyTool),
),
),
)
}
}
}
@Test
fun `toolChoice field rejected when client has no sampling tools capability`() {
val (server, sessionId) = buildPair()
assertFailsWith<IllegalArgumentException> {
runBlocking {
server.createMessage(
sessionId = sessionId,
params = CreateMessageRequest(
params = CreateMessageRequestParams(
maxTokens = 100,
messages = minimalMessages,
toolChoice = ToolChoice(),
),
),
)
}
}
}
@Test
fun `includeContext with no sampling context capability succeeds with a warning`() {
val (server, sessionId) = buildPair()
assertDoesNotThrow {
runBlocking {
server.createMessage(
sessionId = sessionId,
params = CreateMessageRequest(
params = CreateMessageRequestParams(
maxTokens = 100,
messages = minimalMessages,
includeContext = IncludeContext.ThisServer,
),
),
)
}
}
}
// ============================================================================
// End-to-end tool-loop integration
// ============================================================================
@Test
fun `server sends tools, client returns tool_use then final text`() {
var turn = 0
val (server, sessionId) = buildPair(
clientCapabilities = ClientCapabilities(
sampling = ClientCapabilities.Sampling(tools = EmptyJsonObject),
),
samplingHandler = { _ ->
turn++
if (turn == 1) {
CreateMessageResult(
role = Role.Assistant,
content = listOf(
TextContent("Let me check."),
ToolUseContent(
id = "call_1",
name = "get_weather",
input = buildJsonObject { put("location", JsonPrimitive("London")) },
),
),
model = "test",
stopReason = StopReason.ToolUse,
)
} else {
CreateMessageResult(
role = Role.Assistant,
content = TextContent("The temperature in London is 20°C."),
model = "test",
stopReason = StopReason.EndTurn,
)
}
},
)
runBlocking {
val messages = mutableListOf(
SamplingMessage(Role.User, TextContent("What is the weather in London?")),
)
// — Turn 1: server sends tools, expects tool_use stop reason
val first = server.createMessage(
sessionId = sessionId,
params = CreateMessageRequest(
params = CreateMessageRequestParams(
maxTokens = 256,
messages = messages.toList(),
tools = listOf(weatherTool),
toolChoice = ToolChoice(mode = ToolChoice.Mode.Auto),
),
),
)
assertEquals(StopReason.ToolUse, first.stopReason)
assertEquals(2, first.content.size)
// — Append assistant turn and inject tool result
messages.add(SamplingMessage(Role.Assistant, first.content))
val toolUse = first.content.filterIsInstance<ToolUseContent>().single()
messages.add(
SamplingMessage(
Role.User,
ToolResultContent(
toolUseId = toolUse.id,
content = listOf(TextContent("""{"tempC":20}""")),
),
),
)
// — Turn 2: server sends updated history, expects final text
val second = server.createMessage(
sessionId = sessionId,
params = CreateMessageRequest(
params = CreateMessageRequestParams(
maxTokens = 256,
messages = messages.toList(),
tools = listOf(weatherTool),
toolChoice = ToolChoice(mode = ToolChoice.Mode.Auto),
),
),
)
assertEquals(StopReason.EndTurn, second.stopReason)
assertEquals(1, second.content.size)
val text = second.content.single() as TextContent
assertEquals("The temperature in London is 20°C.", text.text)
server.close()
}
}
}