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
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue