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

@ -4,6 +4,7 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi
import io.modelcontextprotocol.kotlin.sdk.shared.Protocol
import io.modelcontextprotocol.kotlin.sdk.shared.ProtocolOptions
import io.modelcontextprotocol.kotlin.sdk.shared.RequestHandlerExtra
import io.modelcontextprotocol.kotlin.sdk.shared.RequestOptions
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
import io.modelcontextprotocol.kotlin.sdk.types.BooleanSchema
@ -13,6 +14,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.CompleteRequest
import io.modelcontextprotocol.kotlin.sdk.types.CompleteResult
import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest
import io.modelcontextprotocol.kotlin.sdk.types.DoubleSchema
import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequest
import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestFormParams
@ -45,7 +47,9 @@ import io.modelcontextprotocol.kotlin.sdk.types.PingRequest
import io.modelcontextprotocol.kotlin.sdk.types.PrimitiveSchemaDefinition
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult
import io.modelcontextprotocol.kotlin.sdk.types.Request
import io.modelcontextprotocol.kotlin.sdk.types.RequestMeta
import io.modelcontextprotocol.kotlin.sdk.types.RequestResult
import io.modelcontextprotocol.kotlin.sdk.types.Root
import io.modelcontextprotocol.kotlin.sdk.types.RootsListChangedNotification
import io.modelcontextprotocol.kotlin.sdk.types.SUPPORTED_PROTOCOL_VERSIONS
@ -323,6 +327,26 @@ public open class Client(private val clientInfo: Implementation, options: Client
}
}
/**
* Wraps incoming-request handlers with SEP-1577 client-side enforcement.
*
* For `sampling/createMessage`: if the incoming request carries `tools` or
* `toolChoice` but this client did not advertise [ClientCapabilities.Sampling.tools],
* the wrapper throws an [McpException] with JSON-RPC error code `InvalidParams`
* before the user-supplied handler runs. Matches the TypeScript SDK wrapper in
* `Client.setRequestHandler`.
*/
override fun <T : Request> wrapRequestHandler(
method: Method,
block: suspend (T, RequestHandlerExtra) -> RequestResult?,
): suspend (T, RequestHandlerExtra) -> RequestResult? {
if (method != Method.Defined.SamplingCreateMessage) return block
return { request, extra ->
(request as? CreateMessageRequest)?.let { validateSamplingToolsCapability(it, capabilities) }
block(request, extra)
}
}
/**
* Sends a ping request to the server to check connectivity.
*

View file

@ -0,0 +1,24 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest
import io.modelcontextprotocol.kotlin.sdk.types.McpException
import io.modelcontextprotocol.kotlin.sdk.types.RPCError
/**
* Validates SEP-1577 client-side enforcement: an incoming `sampling/createMessage`
* request that carries `tools` or `toolChoice` requires the client to have advertised
* the `sampling.tools` sub-capability. When the capability is missing, throws an
* [McpException] with JSON-RPC error code `InvalidParams`, matching TypeScript SDK
* client enforcement.
*/
internal fun validateSamplingToolsCapability(request: CreateMessageRequest, capabilities: ClientCapabilities) {
val params = request.params
if (params.tools == null && params.toolChoice == null) return
if (capabilities.sampling?.tools != null) return
val field = if (params.tools != null) "tools" else "toolChoice"
throw McpException(
code = RPCError.ErrorCode.INVALID_PARAMS,
message = "Client does not support sampling with tools but request contains $field parameter",
)
}

View file

@ -0,0 +1,98 @@
package io.modelcontextprotocol.kotlin.sdk.client
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.EmptyJsonObject
import io.modelcontextprotocol.kotlin.sdk.types.McpException
import io.modelcontextprotocol.kotlin.sdk.types.RPCError
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.Tool
import io.modelcontextprotocol.kotlin.sdk.types.ToolChoice
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
import kotlinx.serialization.json.buildJsonObject
import org.junit.jupiter.api.assertDoesNotThrow
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class ClientSamplingValidationTest {
private val dummyTool = Tool(
name = "t",
inputSchema = ToolSchema(properties = buildJsonObject { }, required = emptyList()),
)
private val minimalMessages = listOf(SamplingMessage(Role.User, TextContent("hi")))
private val noToolsCaps = ClientCapabilities(sampling = ClientCapabilities.sampling)
private val withToolsCaps = ClientCapabilities(
sampling = ClientCapabilities.Sampling(tools = EmptyJsonObject),
)
@Test
fun `request without tools or toolChoice is always accepted`() {
val request = CreateMessageRequest(
CreateMessageRequestParams(maxTokens = 10, messages = minimalMessages),
)
assertDoesNotThrow { validateSamplingToolsCapability(request, noToolsCaps) }
}
@Test
fun `tools without sampling tools capability throws InvalidParams`() {
val request = CreateMessageRequest(
CreateMessageRequestParams(
maxTokens = 10,
messages = minimalMessages,
tools = listOf(dummyTool),
),
)
val exception = assertFailsWith<McpException> {
validateSamplingToolsCapability(request, noToolsCaps)
}
assertEquals(RPCError.ErrorCode.INVALID_PARAMS, exception.code)
check(exception.message?.contains("tools") == true)
}
@Test
fun `toolChoice without sampling tools capability throws InvalidParams`() {
val request = CreateMessageRequest(
CreateMessageRequestParams(
maxTokens = 10,
messages = minimalMessages,
toolChoice = ToolChoice(mode = ToolChoice.Mode.Required),
),
)
val exception = assertFailsWith<McpException> {
validateSamplingToolsCapability(request, noToolsCaps)
}
assertEquals(RPCError.ErrorCode.INVALID_PARAMS, exception.code)
check(exception.message?.contains("toolChoice") == true)
}
@Test
fun `tools with sampling tools capability is accepted`() {
val request = CreateMessageRequest(
CreateMessageRequestParams(
maxTokens = 10,
messages = minimalMessages,
tools = listOf(dummyTool),
),
)
assertDoesNotThrow { validateSamplingToolsCapability(request, withToolsCaps) }
}
@Test
fun `toolChoice with sampling tools capability is accepted`() {
val request = CreateMessageRequest(
CreateMessageRequestParams(
maxTokens = 10,
messages = minimalMessages,
toolChoice = ToolChoice(mode = ToolChoice.Mode.Auto),
),
)
assertDoesNotThrow { validateSamplingToolsCapability(request, withToolsCaps) }
}
}