From f15f1ac94bcde84828006dde18faa2d8f345071e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= <2554306+chemicL@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:36:57 +0200 Subject: [PATCH] add SEP-1577 sampling with tools support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces parallel V2 sampling types (SamplingMessageV2, CreateMessageWithToolsRequest/Result, ToolUseContent, ToolResultContent, ToolChoice) alongside the existing V1 types, leaving the legacy wire format byte-identical. Servers call createMessageWithTools on the exchange; a version gate refuses multi-content or tools payloads when the negotiated protocol version is older than 2025-11-25. Clients opt in via samplingWithTools(handler) on the builder, which advertises sampling.tools in the capability handshake. StopReason gains TOOL_USE. Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- MIGRATION-2.0.md | 10 + .../client/McpAsyncClient.java | 49 +- .../client/McpClient.java | 45 +- .../client/McpClientFeatures.java | 128 ++-- .../server/McpAsyncServerExchange.java | 106 ++- .../server/McpSyncServerExchange.java | 14 + ...aultMcpStreamableServerSessionFactory.java | 11 +- .../modelcontextprotocol/spec/McpSchema.java | 617 +++++++++++++++++- .../spec/McpServerSession.java | 14 +- .../spec/McpStreamableServerSession.java | 13 +- .../McpAsyncClientResponseHandlerTests.java | 144 ++++ .../spec/McpSchemaTests.java | 164 ++++- 12 files changed, 1234 insertions(+), 81 deletions(-) diff --git a/MIGRATION-2.0.md b/MIGRATION-2.0.md index 2119f71f5..974ae8bdf 100644 --- a/MIGRATION-2.0.md +++ b/MIGRATION-2.0.md @@ -173,3 +173,13 @@ The 1.x canonical 4-arg constructors continue to compile. When a `JsonSchemaValidator` is available (including the default from `McpJsonDefaults.getSchemaValidator()` when you do not configure one explicitly) and `validateToolInputs` is left at its default of `true`, the server validates incoming tool arguments against `tool.inputSchema()` before invoking the tool. Failed validation produces a `CallToolResult` with `isError` set and a textual error in the content. **Action:** Ensure `inputSchema` maps are valid for your validator, tighten client arguments, or disable validation with `validateToolInputs(false)` on the server builder if you must preserve pre-2.0 behaviour. + +--- + +## Sampling with tools (SEP-1577) + +### New `StopReason.TOOL_USE` enum constant + +`McpSchema.CreateMessageResult.StopReason` gains a new value, `TOOL_USE("toolUse")`. Exhaustive `switch` *expressions* over the enum (which the compiler enforces at compile time) that covered all pre-existing constants without a `default` branch will no longer compile. + +**Action:** Add a `case TOOL_USE` branch or a `default` branch to any exhaustive `switch` expression on `StopReason`. diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java index 945221bd0..6fa1fedba 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -103,6 +103,9 @@ public class McpAsyncClient { public static final TypeRef CREATE_MESSAGE_REQUEST_TYPE_REF = new TypeRef<>() { }; + public static final TypeRef CREATE_MESSAGE_WITH_TOOLS_REQUEST_TYPE_REF = new TypeRef<>() { + }; + public static final TypeRef LOGGING_MESSAGE_NOTIFICATION_TYPE_REF = new TypeRef<>() { }; @@ -142,6 +145,8 @@ public class McpAsyncClient { */ private Function> samplingHandler; + private Function> samplingWithToolsHandler; + /** * MCP provides a standardized way for servers to request additional information from * users through the client during interactions. This flow allows clients to maintain @@ -227,11 +232,21 @@ public class McpAsyncClient { // Sampling Handler if (this.clientCapabilities.sampling() != null) { - if (features.samplingHandler() == null) { - throw new IllegalArgumentException( - "Sampling handler must not be null when client capabilities include sampling"); + boolean withTools = this.clientCapabilities.sampling().tools() != null; + if (withTools) { + if (features.samplingWithToolsHandler() == null) { + throw new IllegalArgumentException( + "Sampling-with-tools handler must not be null when client capabilities include sampling.tools"); + } + this.samplingWithToolsHandler = features.samplingWithToolsHandler(); + } + else { + if (features.samplingHandler() == null) { + throw new IllegalArgumentException( + "Sampling handler must not be null when client capabilities include sampling"); + } + this.samplingHandler = features.samplingHandler(); } - this.samplingHandler = features.samplingHandler(); requestHandlers.put(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, samplingCreateMessageHandler()); } @@ -575,11 +590,29 @@ private RequestHandler rootsListRequestHandler() { // -------------------------- // Sampling // -------------------------- - private RequestHandler samplingCreateMessageHandler() { + private RequestHandler samplingCreateMessageHandler() { return params -> { - McpSchema.CreateMessageRequest request = transport.unmarshalFrom(params, CREATE_MESSAGE_REQUEST_TYPE_REF); - - return this.samplingHandler.apply(request); + // Dispatch to V2 handler if the request carries tools/toolChoice, otherwise + // V1 + McpSchema.CreateMessageWithToolsRequest v2Request = transport.unmarshalFrom(params, + CREATE_MESSAGE_WITH_TOOLS_REQUEST_TYPE_REF); + boolean isV2Request = (v2Request.tools() != null && !v2Request.tools().isEmpty()) + || v2Request.toolChoice() != null; + if (isV2Request) { + if (this.samplingWithToolsHandler == null) { + return Mono.error(new IllegalStateException( + "Received sampling request with tools, but no samplingWithTools handler is registered. " + + "Use McpClient.async(transport).samplingWithTools(handler) to register one.")); + } + return this.samplingWithToolsHandler.apply(v2Request).cast(Object.class); + } + // V1 path: unmarshal as the legacy type for handler type-safety + McpSchema.CreateMessageRequest v1Request = transport.unmarshalFrom(params, CREATE_MESSAGE_REQUEST_TYPE_REF); + if (this.samplingHandler == null) { + return Mono.error( + new IllegalStateException("Received sampling request, but no sampling handler is registered.")); + } + return this.samplingHandler.apply(v1Request).cast(Object.class); }; } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java index 1af4eea1b..f59505b78 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java @@ -190,6 +190,8 @@ class SyncSpec { private Function samplingHandler; + private Function samplingWithToolsHandler; + private Function formElicitationHandler; private Function urlElicitationHandler; @@ -310,6 +312,23 @@ public SyncSpec sampling(Function sam return this; } + /** + * Sets a sampling handler that supports tool use (SEP-1577) and registers the + * {@code sampling.tools} client capability. Use this instead of + * {@link #sampling(Function)} when the server may include {@code tools} and + * {@code toolChoice} in its {@code sampling/createMessage} requests. + * @param samplingWithToolsHandler A function that processes sampling-with-tools + * requests and returns results. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if samplingWithToolsHandler is null + */ + public SyncSpec samplingWithTools( + Function samplingWithToolsHandler) { + Assert.notNull(samplingWithToolsHandler, "Sampling-with-tools handler must not be null"); + this.samplingWithToolsHandler = samplingWithToolsHandler; + return this; + } + /** * Sets a custom elicitation handler for processing elicitation message requests. * The elicitation handler can modify or validate messages before they are sent to @@ -554,7 +573,8 @@ public McpSyncClient build() { this.roots, this.toolsChangeConsumers, this.resourcesChangeConsumers, this.resourcesUpdateConsumers, this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers, this.elicitationCompleteConsumers, this.samplingHandler, this.formElicitationHandler, - this.urlElicitationHandler, this.enableCallToolSchemaCaching, this.applyElicitationDefaults); + this.urlElicitationHandler, this.enableCallToolSchemaCaching, this.applyElicitationDefaults, + this.samplingWithToolsHandler); McpClientFeatures.Async asyncFeatures = McpClientFeatures.Async.fromSync(syncFeatures); @@ -611,6 +631,8 @@ class AsyncSpec { private Function> samplingHandler; + private Function> samplingWithToolsHandler; + private Function> formElicitationHandler; private Function> urlElicitationHandler; @@ -729,6 +751,23 @@ public AsyncSpec sampling(Function> samplingWithToolsHandler) { + Assert.notNull(samplingWithToolsHandler, "Sampling-with-tools handler must not be null"); + this.samplingWithToolsHandler = samplingWithToolsHandler; + return this; + } + /** * Sets a custom elicitation handler for processing elicitation message requests. * The elicitation handler can modify or validate messages before they are sent to @@ -964,8 +1003,8 @@ public McpAsyncClient build() { this.toolsChangeConsumers, this.resourcesChangeConsumers, this.resourcesUpdateConsumers, this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers, this.elicitationCompleteConsumers, this.samplingHandler, this.formElicitationHandler, - this.urlElicitationHandler, this.enableCallToolSchemaCaching, - this.applyElicitationDefaults)); + this.urlElicitationHandler, this.enableCallToolSchemaCaching, this.applyElicitationDefaults, + this.samplingWithToolsHandler)); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java index f61123da0..41a0b1a89 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java @@ -78,7 +78,34 @@ record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c Function> samplingHandler, Function> formElicitationHandler, Function> urlElicitationHandler, - boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { + boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults, + Function> samplingWithToolsHandler) { + + Async { + Assert.notNull(clientInfo, "Client info must not be null"); + McpSchema.ClientCapabilities.Sampling samplingCapability = null; + if (samplingWithToolsHandler != null) { + samplingCapability = new McpSchema.ClientCapabilities.Sampling( + new McpSchema.ClientCapabilities.Sampling.SamplingTools()); + } + else if (samplingHandler != null) { + samplingCapability = new McpSchema.ClientCapabilities.Sampling(); + } + final McpSchema.ClientCapabilities.Sampling resolvedSampling = samplingCapability; + clientCapabilities = (clientCapabilities != null) ? clientCapabilities + : new McpSchema.ClientCapabilities(null, + !Utils.isEmpty(roots) ? new McpSchema.ClientCapabilities.RootCapabilities(false) : null, + resolvedSampling, elicitationCapabilities(formElicitationHandler, urlElicitationHandler)); + roots = roots != null ? new ConcurrentHashMap<>(roots) : new ConcurrentHashMap<>(); + toolsChangeConsumers = toolsChangeConsumers != null ? toolsChangeConsumers : List.of(); + resourcesChangeConsumers = resourcesChangeConsumers != null ? resourcesChangeConsumers : List.of(); + resourcesUpdateConsumers = resourcesUpdateConsumers != null ? resourcesUpdateConsumers : List.of(); + promptsChangeConsumers = promptsChangeConsumers != null ? promptsChangeConsumers : List.of(); + loggingConsumers = loggingConsumers != null ? loggingConsumers : List.of(); + progressConsumers = progressConsumers != null ? progressConsumers : List.of(); + elicitationCompleteConsumers = elicitationCompleteConsumers != null ? elicitationCompleteConsumers + : List.of(); + } /** * Create an instance and validate the arguments. @@ -109,29 +136,10 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c Function> formElicitationHandler, Function> urlElicitationHandler, boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { - - Assert.notNull(clientInfo, "Client info must not be null"); - this.clientInfo = clientInfo; - this.clientCapabilities = (clientCapabilities != null) ? clientCapabilities - : new McpSchema.ClientCapabilities(null, - !Utils.isEmpty(roots) ? new McpSchema.ClientCapabilities.RootCapabilities(false) : null, - samplingHandler != null ? new McpSchema.ClientCapabilities.Sampling() : null, - elicitationCapabilities(formElicitationHandler, urlElicitationHandler)); - this.roots = roots != null ? new ConcurrentHashMap<>(roots) : new ConcurrentHashMap<>(); - - this.toolsChangeConsumers = toolsChangeConsumers != null ? toolsChangeConsumers : List.of(); - this.resourcesChangeConsumers = resourcesChangeConsumers != null ? resourcesChangeConsumers : List.of(); - this.resourcesUpdateConsumers = resourcesUpdateConsumers != null ? resourcesUpdateConsumers : List.of(); - this.promptsChangeConsumers = promptsChangeConsumers != null ? promptsChangeConsumers : List.of(); - this.loggingConsumers = loggingConsumers != null ? loggingConsumers : List.of(); - this.progressConsumers = progressConsumers != null ? progressConsumers : List.of(); - this.elicitationCompleteConsumers = elicitationCompleteConsumers != null ? elicitationCompleteConsumers - : List.of(); - this.samplingHandler = samplingHandler; - this.formElicitationHandler = formElicitationHandler; - this.urlElicitationHandler = urlElicitationHandler; - this.enableCallToolSchemaCaching = enableCallToolSchemaCaching; - this.applyElicitationDefaults = applyElicitationDefaults; + this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers, + resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, progressConsumers, + elicitationCompleteConsumers, samplingHandler, formElicitationHandler, urlElicitationHandler, + enableCallToolSchemaCaching, applyElicitationDefaults, null); } /** @@ -203,9 +211,11 @@ public static Async fromSync(Sync syncSpec) { .subscribeOn(Schedulers.boundedElastic())); } - Function> samplingHandler = r -> Mono - .fromCallable(() -> syncSpec.samplingHandler().apply(r)) - .subscribeOn(Schedulers.boundedElastic()); + Function> samplingHandler = syncSpec + .samplingHandler() != null + ? r -> Mono.fromCallable(() -> syncSpec.samplingHandler().apply(r)) + .subscribeOn(Schedulers.boundedElastic()) + : null; Function> formElicitationHandler = syncSpec .formElicitationHandler() != null @@ -219,11 +229,17 @@ public static Async fromSync(Sync syncSpec) { .subscribeOn(Schedulers.boundedElastic()) : null; + Function> samplingWithToolsHandler = syncSpec + .samplingWithToolsHandler() != null + ? r -> Mono.fromCallable(() -> syncSpec.samplingWithToolsHandler().apply(r)) + .subscribeOn(Schedulers.boundedElastic()) + : null; + return new Async(syncSpec.clientInfo(), syncSpec.clientCapabilities(), syncSpec.roots(), toolsChangeConsumers, resourcesChangeConsumers, resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, progressConsumers, elicitationCompleteConsumers, samplingHandler, formElicitationHandler, urlElicitationHandler, syncSpec.enableCallToolSchemaCaching, - syncSpec.applyElicitationDefaults); + syncSpec.applyElicitationDefaults, samplingWithToolsHandler); } } @@ -258,7 +274,34 @@ public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabili Function samplingHandler, Function formElicitationHandler, Function urlElicitationHandler, - boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { + boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults, + Function samplingWithToolsHandler) { + + public Sync { + Assert.notNull(clientInfo, "Client info must not be null"); + McpSchema.ClientCapabilities.Sampling samplingCapability = null; + if (samplingWithToolsHandler != null) { + samplingCapability = new McpSchema.ClientCapabilities.Sampling( + new McpSchema.ClientCapabilities.Sampling.SamplingTools()); + } + else if (samplingHandler != null) { + samplingCapability = new McpSchema.ClientCapabilities.Sampling(); + } + final McpSchema.ClientCapabilities.Sampling resolvedSampling = samplingCapability; + clientCapabilities = (clientCapabilities != null) ? clientCapabilities + : new McpSchema.ClientCapabilities(null, + !Utils.isEmpty(roots) ? new McpSchema.ClientCapabilities.RootCapabilities(false) : null, + resolvedSampling, elicitationCapabilities(formElicitationHandler, urlElicitationHandler)); + roots = roots != null ? new HashMap<>(roots) : new HashMap<>(); + toolsChangeConsumers = toolsChangeConsumers != null ? toolsChangeConsumers : List.of(); + resourcesChangeConsumers = resourcesChangeConsumers != null ? resourcesChangeConsumers : List.of(); + resourcesUpdateConsumers = resourcesUpdateConsumers != null ? resourcesUpdateConsumers : List.of(); + promptsChangeConsumers = promptsChangeConsumers != null ? promptsChangeConsumers : List.of(); + loggingConsumers = loggingConsumers != null ? loggingConsumers : List.of(); + progressConsumers = progressConsumers != null ? progressConsumers : List.of(); + elicitationCompleteConsumers = elicitationCompleteConsumers != null ? elicitationCompleteConsumers + : List.of(); + } /** * Create an instance and validate the arguments. @@ -290,29 +333,10 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl Function formElicitationHandler, Function urlElicitationHandler, boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { - - Assert.notNull(clientInfo, "Client info must not be null"); - this.clientInfo = clientInfo; - this.clientCapabilities = (clientCapabilities != null) ? clientCapabilities - : new McpSchema.ClientCapabilities(null, - !Utils.isEmpty(roots) ? new McpSchema.ClientCapabilities.RootCapabilities(false) : null, - samplingHandler != null ? new McpSchema.ClientCapabilities.Sampling() : null, - elicitationCapabilities(formElicitationHandler, urlElicitationHandler)); - this.roots = roots != null ? new HashMap<>(roots) : new HashMap<>(); - - this.toolsChangeConsumers = toolsChangeConsumers != null ? toolsChangeConsumers : List.of(); - this.resourcesChangeConsumers = resourcesChangeConsumers != null ? resourcesChangeConsumers : List.of(); - this.resourcesUpdateConsumers = resourcesUpdateConsumers != null ? resourcesUpdateConsumers : List.of(); - this.promptsChangeConsumers = promptsChangeConsumers != null ? promptsChangeConsumers : List.of(); - this.loggingConsumers = loggingConsumers != null ? loggingConsumers : List.of(); - this.progressConsumers = progressConsumers != null ? progressConsumers : List.of(); - this.elicitationCompleteConsumers = elicitationCompleteConsumers != null ? elicitationCompleteConsumers - : List.of(); - this.samplingHandler = samplingHandler; - this.formElicitationHandler = formElicitationHandler; - this.urlElicitationHandler = urlElicitationHandler; - this.enableCallToolSchemaCaching = enableCallToolSchemaCaching; - this.applyElicitationDefaults = applyElicitationDefaults; + this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers, + resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, progressConsumers, + elicitationCompleteConsumers, samplingHandler, formElicitationHandler, urlElicitationHandler, + enableCallToolSchemaCaching, applyElicitationDefaults, null); } /** diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java index e27d6128f..f0a486d70 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java @@ -38,9 +38,14 @@ public class McpAsyncServerExchange { private final JsonSchemaValidator jsonSchemaValidator; + private final String negotiatedProtocolVersion; + private static final TypeRef CREATE_MESSAGE_RESULT_TYPE_REF = new TypeRef<>() { }; + private static final TypeRef CREATE_MESSAGE_WITH_TOOLS_RESULT_TYPE_REF = new TypeRef<>() { + }; + private static final TypeRef LIST_ROOTS_RESULT_TYPE_REF = new TypeRef<>() { }; @@ -60,16 +65,37 @@ public class McpAsyncServerExchange { * @param transportContext context associated with the client as extracted from the * transport * @param jsonSchemaValidator optional validator used to verify elicitation schemas + * @param negotiatedProtocolVersion the protocol version negotiated during + * initialization, or {@code null} if unknown */ public McpAsyncServerExchange(String sessionId, McpLoggableSession session, McpSchema.ClientCapabilities clientCapabilities, McpSchema.Implementation clientInfo, - McpTransportContext transportContext, JsonSchemaValidator jsonSchemaValidator) { + McpTransportContext transportContext, JsonSchemaValidator jsonSchemaValidator, + String negotiatedProtocolVersion) { this.sessionId = sessionId; this.session = session; this.clientCapabilities = clientCapabilities; this.clientInfo = clientInfo; this.transportContext = transportContext; this.jsonSchemaValidator = jsonSchemaValidator; + this.negotiatedProtocolVersion = negotiatedProtocolVersion; + } + + /** + * Create a new asynchronous exchange with the client. + * @param sessionId the session ID + * @param session The server session representing a 1-1 interaction. + * @param clientCapabilities The client capabilities that define the supported + * features and functionality. + * @param clientInfo The client implementation information. + * @param transportContext context associated with the client as extracted from the + * transport + * @param jsonSchemaValidator optional validator used to verify elicitation schemas + */ + public McpAsyncServerExchange(String sessionId, McpLoggableSession session, + McpSchema.ClientCapabilities clientCapabilities, McpSchema.Implementation clientInfo, + McpTransportContext transportContext, JsonSchemaValidator jsonSchemaValidator) { + this(sessionId, session, clientCapabilities, clientInfo, transportContext, jsonSchemaValidator, null); } /** @@ -85,7 +111,7 @@ public McpAsyncServerExchange(String sessionId, McpLoggableSession session, public McpAsyncServerExchange(String sessionId, McpLoggableSession session, McpSchema.ClientCapabilities clientCapabilities, McpSchema.Implementation clientInfo, McpTransportContext transportContext) { - this(sessionId, session, clientCapabilities, clientInfo, transportContext, null); + this(sessionId, session, clientCapabilities, clientInfo, transportContext, null, null); } /** @@ -150,6 +176,82 @@ public Mono createMessage(McpSchema.CreateMessage CREATE_MESSAGE_RESULT_TYPE_REF); } + /** + * Create a new message using the sampling-with-tools capabilities of the client + * (SEP-1577). The request may include tool definitions and a tool choice mode. The + * client drives its LLM with these tools and returns a + * {@link McpSchema.CreateMessageWithToolsResult} whose content may include + * {@link McpSchema.ToolUseContent} blocks. + * + *

+ * Wire-format version gate: if the negotiated protocol version is + * older than {@link McpSchema#PROTOCOL_VERSION_SAMPLING_WITH_TOOLS} + * ({@code 2025-11-25}), this method rejects requests that contain {@code tools}, a + * {@code toolChoice}, or any message whose content list has more than one block. Such + * requests cannot be round-tripped by a legacy peer without data loss. + * + *

+ * Note: the JSON-RPC method sent on the wire is the same + * {@code sampling/createMessage} as {@link #createMessage}. What differs is the + * richer V2 schema used to serialize the parameters and deserialize the result. + * @param request The sampling-with-tools request + * @return A Mono that emits the result when the client has responded + * @see McpSchema.CreateMessageWithToolsRequest + * @see McpSchema.CreateMessageWithToolsResult + */ + public Mono createMessageWithTools( + McpSchema.CreateMessageWithToolsRequest request) { + if (this.clientCapabilities == null) { + return Mono + .error(new IllegalStateException("Client must be initialized. Call the initialize method first!")); + } + if (this.clientCapabilities.sampling() == null) { + return Mono.error(new IllegalStateException("Client must be configured with sampling capabilities")); + } + if (this.clientCapabilities.sampling().tools() == null) { + return Mono + .error(new IllegalStateException("Client must be configured with sampling-with-tools capabilities")); + } + + // Version gate: refuse multi-content or tools if the peer is pre-2025-11-25 + if (isOlderThanSamplingWithToolsVersion(this.negotiatedProtocolVersion)) { + if (request.tools() != null && !request.tools().isEmpty()) { + return Mono.error( + new IllegalStateException("Cannot send tools in sampling request: negotiated protocol version '" + + this.negotiatedProtocolVersion + "' is older than '" + + McpSchema.PROTOCOL_VERSION_SAMPLING_WITH_TOOLS + "'")); + } + if (request.toolChoice() != null) { + return Mono.error(new IllegalStateException( + "Cannot send toolChoice in sampling request: negotiated protocol version '" + + this.negotiatedProtocolVersion + "' is older than '" + + McpSchema.PROTOCOL_VERSION_SAMPLING_WITH_TOOLS + "'")); + } + for (int i = 0; i < request.messages().size(); i++) { + if (request.messages().get(i).content().size() > 1) { + return Mono.error(new IllegalStateException("Cannot send multi-content message at index " + i + + " in sampling request: negotiated protocol version '" + this.negotiatedProtocolVersion + + "' is older than '" + McpSchema.PROTOCOL_VERSION_SAMPLING_WITH_TOOLS + "'")); + } + } + } + + return this.session.sendRequest(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, request, + CREATE_MESSAGE_WITH_TOOLS_RESULT_TYPE_REF); + } + + /** + * Returns {@code true} when the given version string is older than + * {@link McpSchema#PROTOCOL_VERSION_SAMPLING_WITH_TOOLS}, or when the version is + * {@code null} (unknown — treated as pre-SEP-1577). + */ + private static boolean isOlderThanSamplingWithToolsVersion(String negotiatedVersion) { + if (negotiatedVersion == null) { + return true; + } + return negotiatedVersion.compareTo(McpSchema.PROTOCOL_VERSION_SAMPLING_WITH_TOOLS) < 0; + } + /** * Creates a new elicitation. MCP provides a standardized way for servers to request * additional information from users through the client during interactions. This flow diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncServerExchange.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncServerExchange.java index 0b9115b79..e782479bb 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncServerExchange.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncServerExchange.java @@ -82,6 +82,20 @@ public McpSchema.CreateMessageResult createMessage(McpSchema.CreateMessageReques return this.exchange.createMessage(createMessageRequest).block(); } + /** + * Create a new message using the sampling-with-tools capabilities of the client + * (SEP-1577). Blocking variant of + * {@link McpAsyncServerExchange#createMessageWithTools}. + * @param request The sampling-with-tools request + * @return The result containing the details of the sampling response + * @see McpSchema.CreateMessageWithToolsRequest + * @see McpSchema.CreateMessageWithToolsResult + */ + public McpSchema.CreateMessageWithToolsResult createMessageWithTools( + McpSchema.CreateMessageWithToolsRequest request) { + return this.exchange.createMessageWithTools(request).block(); + } + /** * Creates a new elicitation. MCP provides a standardized way for servers to request * additional information from users through the client during interactions. This flow diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/DefaultMcpStreamableServerSessionFactory.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/DefaultMcpStreamableServerSessionFactory.java index aa0843626..e415adb95 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/DefaultMcpStreamableServerSessionFactory.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/DefaultMcpStreamableServerSessionFactory.java @@ -95,11 +95,12 @@ public DefaultMcpStreamableServerSessionFactory(Duration requestTimeout, public McpStreamableServerSession.McpStreamableServerSessionInit startSession( McpSchema.InitializeRequest initializeRequest) { String sessionId = UUID.randomUUID().toString(); - return new McpStreamableServerSession.McpStreamableServerSessionInit( - new McpStreamableServerSession(sessionId, initializeRequest.capabilities(), - initializeRequest.clientInfo(), requestTimeout, requestHandlers, notificationHandlers, - () -> this.onClose.apply(sessionId), this.jsonSchemaValidator), - this.initRequestHandler.handle(initializeRequest)); + McpStreamableServerSession session = new McpStreamableServerSession(sessionId, initializeRequest.capabilities(), + initializeRequest.clientInfo(), requestTimeout, requestHandlers, notificationHandlers, + () -> this.onClose.apply(sessionId), this.jsonSchemaValidator); + return new McpStreamableServerSession.McpStreamableServerSessionInit(session, + this.initRequestHandler.handle(initializeRequest) + .doOnNext(result -> session.setNegotiatedProtocolVersion(result.protocolVersion()))); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index 1366a5efd..7107f85ac 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -11,6 +11,7 @@ import java.util.Map; import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; @@ -109,6 +110,14 @@ private McpSchema() { // Sampling Methods public static final String METHOD_SAMPLING_CREATE_MESSAGE = "sampling/createMessage"; + /** + * Minimum negotiated protocol version that supports SEP-1577 "Sampling with Tools". + * Clients advertising {@code sampling.tools} and servers calling + * {@link io.modelcontextprotocol.server.McpAsyncServerExchange#createMessageWithTools} + * must have negotiated at least this version. + */ + public static final String PROTOCOL_VERSION_SAMPLING_WITH_TOOLS = "2025-11-25"; + // Elicitation Methods public static final String METHOD_ELICITATION_CREATE = "elicitation/create"; @@ -622,10 +631,28 @@ public RootCapabilities build() { * servers to leverage AI capabilities—with no server API keys necessary. Servers * can request text or image-based interactions and optionally include context * from MCP servers in their prompts. + * + * @param tools Present when the client supports sampling with tools (SEP-1577). + * When non-null the client can handle {@code ToolUseContent} and + * {@code ToolResultContent} blocks and the {@code "toolUse"} stop reason. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) - public record Sampling() { + public record Sampling(@JsonProperty("tools") SamplingTools tools) { + + /** Backward-compatible no-arg constructor — produces {@code sampling: {}}. */ + public Sampling() { + this(null); + } + + /** + * Marker record that, when present, signals tool-aware sampling support + * (SEP-1577). Serializes as {@code {}}. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record SamplingTools() { + } } /** @@ -737,6 +764,16 @@ public Builder sampling() { return this; } + /** + * Enables sampling capability and advertises support for sampling with tools + * (SEP-1577). Produces {@code "sampling": {"tools": {}}}. + * @return this builder + */ + public Builder samplingTools() { + this.sampling = new Sampling(new Sampling.SamplingTools()); + return this; + } + /** * Enables elicitation capability with default settings (backward compatible, * produces empty JSON object). @@ -3055,6 +3092,47 @@ public Tool build() { } } + /** + * Specifies how tools should be selected during a sampling request (SEP-1577). The + * {@link #mode} field holds one of the string constants defined here. + * + * @param mode One of {@link #MODE_AUTO}, {@link #MODE_REQUIRED}, or + * {@link #MODE_NONE}. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ToolChoice(@JsonProperty("mode") String mode) { + + /** The LLM decides whether to call a tool. */ + public static final String MODE_AUTO = "auto"; + + /** The LLM must call at least one tool. */ + public static final String MODE_REQUIRED = "required"; + + /** Tool calling is disabled for this request. */ + public static final String MODE_NONE = "none"; + + public ToolChoice { + Assert.hasText(mode, "mode must not be empty"); + } + + /** Factory for {@code {"mode":"auto"}}. */ + public static ToolChoice auto() { + return new ToolChoice(MODE_AUTO); + } + + /** Factory for {@code {"mode":"required"}}. */ + public static ToolChoice required() { + return new ToolChoice(MODE_REQUIRED); + } + + /** Factory for {@code {"mode":"none"}}. */ + public static ToolChoice none() { + return new ToolChoice(MODE_NONE); + } + + } + private static Map schemaToMap(McpJsonMapper jsonMapper, String schema) { try { return jsonMapper.readValue(schema, MAP_TYPE_REF); @@ -3521,6 +3599,85 @@ public SamplingMessage build() { } } + /** + * A conversation message used in sampling-with-tools requests (SEP-1577). Unlike the + * legacy {@link SamplingMessage}, the content is a list of blocks which allows a + * single assistant turn to carry multiple {@link ToolUseContent} blocks (parallel + * tool calls), and a single user turn to carry multiple {@link ToolResultContent} + * blocks. + * + *

+ * On deserialization, a bare JSON object is accepted in addition to a JSON array (via + * {@link JsonFormat.Feature#ACCEPT_SINGLE_VALUE_AS_ARRAY}) so that legacy peers that + * still emit a single-block object are handled transparently. + * + * @param role the role of the message sender + * @param content one or more content blocks + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record SamplingMessageV2( // @formatter:off + @JsonProperty("role") Role role, + @JsonProperty("content") + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + List content) { // @formatter:on + + public SamplingMessageV2 { + Assert.notNull(role, "role must not be null"); + Assert.notNull(content, "content must not be null"); + } + + @JsonCreator + static SamplingMessageV2 fromJson(@JsonProperty("role") Role role, + @JsonProperty("content") List content) { + if (role == null || content == null || content.isEmpty()) { + List missing = new ArrayList<>(); + if (role == null) { + missing.add("role -> 'user'"); + role = Role.USER; + } + if (content == null || content.isEmpty()) { + missing.add("content -> ['']"); + content = List.of(TextContent.builder("").build()); + } + logger.warn("SamplingMessageV2: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new SamplingMessageV2(role, content); + } + + public static SamplingMessageV2 of(Role role, Content single) { + return new SamplingMessageV2(role, List.of(single)); + } + + public static Builder builder(Role role, Content single) { + return new Builder(role, List.of(single)); + } + + public static Builder builder(Role role, List content) { + return new Builder(role, content); + } + + public static class Builder { + + private final Role role; + + private final List content; + + private Builder(Role role, List content) { + Assert.notNull(role, "role must not be null"); + Assert.notNull(content, "content must not be null"); + this.role = role; + this.content = content; + } + + public SamplingMessageV2 build() { + return new SamplingMessageV2(role, content); + } + + } + } + /** * A request from the server to sample an LLM via the client. The client has full * discretion over which model to select. The client should also inform the user @@ -3717,7 +3874,178 @@ public CreateMessageRequest build() { } } - // TODO: role, content and model are required + /** + * A sampling-with-tools request from the server (SEP-1577). Carries the same fields + * as {@link CreateMessageRequest} but uses {@link SamplingMessageV2} for messages + * (which support multi-block content) and adds optional {@code tools} and + * {@code toolChoice} parameters. + * + *

+ * This request is sent over the same {@code sampling/createMessage} JSON-RPC method. + * Use + * {@link io.modelcontextprotocol.server.McpAsyncServerExchange#createMessageWithTools} + * rather than constructing this record directly; the exchange enforces the + * wire-format version gate. + * + * @param messages The conversation messages to send to the LLM + * @param modelPreferences The server's preferences for which model to select + * @param systemPrompt An optional system prompt + * @param includeContext A request to include context from MCP servers + * @param temperature Optional temperature parameter + * @param maxTokens The maximum number of tokens to sample + * @param stopSequences Optional stop sequences + * @param metadata Optional metadata to pass through to the LLM provider + * @param meta See specification for notes on _meta usage + * @param tools Tool definitions scoped to this sampling request. They do not need to + * correspond to tools registered via {@code tools/list}. + * @param toolChoice How tools should be selected; defaults to auto when null. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record CreateMessageWithToolsRequest( // @formatter:off + @JsonProperty("messages") List messages, + @JsonProperty("modelPreferences") ModelPreferences modelPreferences, + @JsonProperty("systemPrompt") String systemPrompt, + @JsonProperty("includeContext") CreateMessageRequest.ContextInclusionStrategy includeContext, + @JsonProperty("temperature") Double temperature, + @JsonProperty("maxTokens") Integer maxTokens, + @JsonProperty("stopSequences") List stopSequences, + @JsonProperty("metadata") Map metadata, + @JsonProperty("_meta") Map meta, + @JsonProperty("tools") List tools, + @JsonProperty("toolChoice") ToolChoice toolChoice) implements Request { // @formatter:on + + public CreateMessageWithToolsRequest { + Assert.notNull(messages, "messages must not be null"); + Assert.notNull(maxTokens, "maxTokens must not be null"); + } + + @JsonCreator + static CreateMessageWithToolsRequest fromJson(@JsonProperty("messages") List messages, + @JsonProperty("modelPreferences") ModelPreferences modelPreferences, + @JsonProperty("systemPrompt") String systemPrompt, + @JsonProperty("includeContext") CreateMessageRequest.ContextInclusionStrategy includeContext, + @JsonProperty("temperature") Double temperature, @JsonProperty("maxTokens") Integer maxTokens, + @JsonProperty("stopSequences") List stopSequences, + @JsonProperty("metadata") Map metadata, @JsonProperty("_meta") Map meta, + @JsonProperty("tools") List tools, @JsonProperty("toolChoice") ToolChoice toolChoice) { + if (messages == null || maxTokens == null) { + List missing = new ArrayList<>(); + if (messages == null) { + missing.add("messages -> []"); + messages = List.of(); + } + if (maxTokens == null) { + missing.add("maxTokens -> 0"); + maxTokens = 0; + } + logger.warn("CreateMessageWithToolsRequest: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new CreateMessageWithToolsRequest(messages, modelPreferences, systemPrompt, includeContext, + temperature, maxTokens, stopSequences, metadata, meta, tools, toolChoice); + } + + public static Builder builder(List messages, int maxTokens) { + return new Builder(messages, maxTokens); + } + + public static class Builder { + + private List messages; + + private ModelPreferences modelPreferences; + + private String systemPrompt; + + private CreateMessageRequest.ContextInclusionStrategy includeContext; + + private Double temperature; + + private Integer maxTokens; + + private List stopSequences; + + private Map metadata; + + private Map meta; + + private List tools; + + private ToolChoice toolChoice; + + private Builder(List messages, int maxTokens) { + Assert.notNull(messages, "messages must not be null"); + this.messages = messages; + this.maxTokens = maxTokens; + } + + public Builder messages(List messages) { + Assert.notNull(messages, "messages must not be null"); + this.messages = messages; + return this; + } + + public Builder modelPreferences(ModelPreferences modelPreferences) { + this.modelPreferences = modelPreferences; + return this; + } + + public Builder systemPrompt(String systemPrompt) { + this.systemPrompt = systemPrompt; + return this; + } + + public Builder includeContext(CreateMessageRequest.ContextInclusionStrategy includeContext) { + this.includeContext = includeContext; + return this; + } + + public Builder temperature(Double temperature) { + this.temperature = temperature; + return this; + } + + public Builder maxTokens(int maxTokens) { + this.maxTokens = maxTokens; + return this; + } + + public Builder stopSequences(List stopSequences) { + this.stopSequences = stopSequences; + return this; + } + + public Builder metadata(Map metadata) { + this.metadata = metadata; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public Builder tools(List tools) { + this.tools = tools; + return this; + } + + public Builder toolChoice(ToolChoice toolChoice) { + this.toolChoice = toolChoice; + return this; + } + + public CreateMessageWithToolsRequest build() { + Assert.notNull(messages, "messages must not be null"); + Assert.notNull(maxTokens, "maxTokens must not be null"); + return new CreateMessageWithToolsRequest(messages, modelPreferences, systemPrompt, includeContext, + temperature, maxTokens, stopSequences, metadata, meta, tools, toolChoice); + } + + } + } + /** * The client's response to a sampling/create_message request from the server. The * client should inform the user before returning the sampled message, to allow them @@ -3776,6 +4104,7 @@ public enum StopReason { @JsonProperty("endTurn") END_TURN("endTurn"), @JsonProperty("stopSequence") STOP_SEQUENCE("stopSequence"), @JsonProperty("maxTokens") MAX_TOKENS("maxTokens"), + @JsonProperty("toolUse") TOOL_USE("toolUse"), @JsonProperty("unknown") UNKNOWN("unknown"); // @formatter:on private final String value; @@ -3887,6 +4216,106 @@ public CreateMessageResult build() { } } + /** + * The client's response to a sampling-with-tools request (SEP-1577). Carries a list + * of content blocks rather than the single-block + * {@link CreateMessageResult#content()} so that the LLM can return multiple + * {@link ToolUseContent} blocks in one turn (parallel tool calls). + * + *

+ * On deserialization, a bare JSON object is accepted as a single-element list via + * {@link JsonFormat.Feature#ACCEPT_SINGLE_VALUE_AS_ARRAY}. + * + * @param role The role of the message sender (typically assistant) + * @param content One or more content blocks + * @param model The name of the model that generated the message + * @param stopReason The reason why sampling stopped + * @param meta See specification for notes on _meta usage + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record CreateMessageWithToolsResult( // @formatter:off + @JsonProperty("role") Role role, + @JsonProperty("content") + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + List content, + @JsonProperty("model") String model, + @JsonProperty("stopReason") CreateMessageResult.StopReason stopReason, + @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + + public CreateMessageWithToolsResult { + Assert.notNull(role, "role must not be null"); + Assert.notNull(content, "content must not be null"); + Assert.notNull(model, "model must not be null"); + } + + @JsonCreator + static CreateMessageWithToolsResult fromJson(@JsonProperty("role") Role role, + @JsonProperty("content") List content, @JsonProperty("model") String model, + @JsonProperty("stopReason") CreateMessageResult.StopReason stopReason, + @JsonProperty("_meta") Map meta) { + if (role == null || content == null || content.isEmpty() || model == null) { + List missing = new ArrayList<>(); + if (role == null) { + missing.add("role -> 'assistant'"); + role = Role.ASSISTANT; + } + if (content == null || content.isEmpty()) { + missing.add("content -> ['']"); + content = List.of(TextContent.builder("").build()); + } + if (model == null) { + missing.add("model -> ''"); + model = ""; + } + logger.warn("CreateMessageWithToolsResult: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new CreateMessageWithToolsResult(role, content, model, stopReason, meta); + } + + public static Builder builder(Role role, List content, String model) { + return new Builder(role, content, model); + } + + public static class Builder { + + private Role role; + + private List content; + + private String model; + + private CreateMessageResult.StopReason stopReason = CreateMessageResult.StopReason.END_TURN; + + private Map meta; + + private Builder(Role role, List content, String model) { + Assert.notNull(role, "role must not be null"); + Assert.notNull(content, "content must not be null"); + Assert.notNull(model, "model must not be null"); + this.role = role; + this.content = content; + this.model = model; + } + + public Builder stopReason(CreateMessageResult.StopReason stopReason) { + this.stopReason = stopReason; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public CreateMessageWithToolsResult build() { + return new CreateMessageWithToolsResult(role, content, model, stopReason, meta); + } + + } + } + // Elicitation /** * A request from the server to elicit additional information from the user, either @@ -5021,7 +5450,9 @@ public CompleteCompletion(List values) { @JsonSubTypes.Type(value = ImageContent.class, name = "image"), @JsonSubTypes.Type(value = AudioContent.class, name = "audio"), @JsonSubTypes.Type(value = EmbeddedResource.class, name = "resource"), - @JsonSubTypes.Type(value = ResourceLink.class, name = "resource_link") }) + @JsonSubTypes.Type(value = ResourceLink.class, name = "resource_link"), + @JsonSubTypes.Type(value = ToolUseContent.class, name = "tool_use"), + @JsonSubTypes.Type(value = ToolResultContent.class, name = "tool_result") }) public interface Content extends Meta { @JsonIgnore @@ -5041,6 +5472,12 @@ else if (this instanceof EmbeddedResource) { else if (this instanceof ResourceLink) { return "resource_link"; } + else if (this instanceof ToolUseContent) { + return "tool_use"; + } + else if (this instanceof ToolResultContent) { + return "tool_result"; + } throw new IllegalArgumentException("Unknown content type: " + this); } @@ -5463,6 +5900,180 @@ public ResourceLink build() { } } + /** + * An assistant-role content block produced when the LLM decides to call a tool + * (SEP-1577). Only valid in {@link SamplingMessageV2} and + * {@link CreateMessageWithToolsResult} content lists. + * + * @param id Unique identifier for this tool call. Must be echoed back in the matching + * {@link ToolResultContent#toolUseId()}. + * @param name The name of the tool to call. + * @param input The arguments to pass to the tool, as a JSON-object-shaped map. + * @param meta See specification for notes on _meta usage + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ToolUseContent( // @formatter:off + @JsonProperty("id") String id, + @JsonProperty("name") String name, + @JsonProperty("input") Map input, + @JsonProperty("_meta") Map meta) implements Content { // @formatter:on + + public ToolUseContent { + Assert.hasText(id, "id must not be empty"); + Assert.hasText(name, "name must not be empty"); + } + + @JsonCreator + static ToolUseContent fromJson(@JsonProperty("id") String id, @JsonProperty("name") String name, + @JsonProperty("input") Map input, @JsonProperty("_meta") Map meta) { + if (id == null || name == null) { + List missing = new ArrayList<>(); + if (id == null) { + missing.add("id -> ''"); + id = ""; + } + if (name == null) { + missing.add("name -> ''"); + name = ""; + } + logger.warn("ToolUseContent: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new ToolUseContent(id, name, input, meta); + } + + /** Convenience constructor without {@code _meta}. */ + public ToolUseContent(String id, String name, Map input) { + this(id, name, input, null); + } + + public static Builder builder(String id, String name) { + return new Builder(id, name); + } + + public static class Builder { + + private final String id; + + private final String name; + + private Map input; + + private Map meta; + + private Builder(String id, String name) { + Assert.hasText(id, "id must not be empty"); + Assert.hasText(name, "name must not be empty"); + this.id = id; + this.name = name; + } + + public Builder input(Map input) { + this.input = input; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public ToolUseContent build() { + return new ToolUseContent(id, name, input, meta); + } + + } + } + + /** + * A user-role content block that carries the result of executing a tool call + * (SEP-1577). Must be paired with a preceding {@link ToolUseContent} whose {@code id} + * matches {@link #toolUseId()}. Only valid in {@link SamplingMessageV2} content + * lists. + * + * @param toolUseId The {@link ToolUseContent#id()} of the tool call being answered. + * @param content The tool output as a list of content blocks (typically text or + * image). May be null when only {@link #structuredContent()} is provided. + * @param structuredContent Optional structured (JSON) form of the result. + * @param isError When {@code true} the tool invocation failed and {@code content} + * contains an error description. + * @param meta See specification for notes on _meta usage + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ToolResultContent( // @formatter:off + @JsonProperty("toolUseId") String toolUseId, + @JsonProperty("content") List content, + @JsonProperty("structuredContent") Map structuredContent, + @JsonProperty("isError") Boolean isError, + @JsonProperty("_meta") Map meta) implements Content { // @formatter:on + + public ToolResultContent { + Assert.hasText(toolUseId, "toolUseId must not be empty"); + } + + @JsonCreator + static ToolResultContent fromJson(@JsonProperty("toolUseId") String toolUseId, + @JsonProperty("content") List content, + @JsonProperty("structuredContent") Map structuredContent, + @JsonProperty("isError") Boolean isError, @JsonProperty("_meta") Map meta) { + if (toolUseId == null) { + logger.warn( + "ToolResultContent: missing required field 'toolUseId' during deserialization, using default ''"); + toolUseId = ""; + } + return new ToolResultContent(toolUseId, content, structuredContent, isError, meta); + } + + public static Builder builder(String toolUseId) { + return new Builder(toolUseId); + } + + public static class Builder { + + private final String toolUseId; + + private List content; + + private Map structuredContent; + + private Boolean isError; + + private Map meta; + + private Builder(String toolUseId) { + Assert.hasText(toolUseId, "toolUseId must not be empty"); + this.toolUseId = toolUseId; + } + + public Builder content(List content) { + this.content = content; + return this; + } + + public Builder structuredContent(Map structuredContent) { + this.structuredContent = structuredContent; + return this; + } + + public Builder isError(Boolean isError) { + this.isError = isError; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public ToolResultContent build() { + return new ToolResultContent(toolUseId, content, structuredContent, isError, meta); + } + + } + } + // --------------------------- // Roots // --------------------------- diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java index 8f86138f0..df5c6d39d 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java @@ -57,6 +57,8 @@ public class McpServerSession implements McpLoggableSession { private final AtomicReference clientInfo = new AtomicReference<>(); + private final AtomicReference negotiatedProtocolVersion = new AtomicReference<>(); + private static final int STATE_UNINITIALIZED = 0; private static final int STATE_INITIALIZING = 1; @@ -284,7 +286,9 @@ private Mono handleIncomingRequest(McpSchema.JSONRPCR this.state.lazySet(STATE_INITIALIZING); this.init(initializeRequest.capabilities(), initializeRequest.clientInfo()); - resultMono = this.initRequestHandler.handle(initializeRequest); + resultMono = this.initRequestHandler.handle(initializeRequest) + .doOnNext(r -> this.negotiatedProtocolVersion + .set(((McpSchema.InitializeResult) r).protocolVersion())); } else { // TODO handle errors for communication to this session without @@ -324,8 +328,9 @@ private Mono handleIncomingNotification(McpSchema.JSONRPCNotification noti this.state.lazySet(STATE_INITIALIZED); // FIXME: The session ID passed here is not the same as the one in the // legacy SSE transport. - exchangeSink.tryEmitValue(new McpAsyncServerExchange(this.id, this, clientCapabilities.get(), - clientInfo.get(), transportContext, this.jsonSchemaValidator)); + exchangeSink + .tryEmitValue(new McpAsyncServerExchange(this.id, this, clientCapabilities.get(), clientInfo.get(), + transportContext, this.jsonSchemaValidator, this.negotiatedProtocolVersion.get())); } var handler = notificationHandlers.get(notification.method()); @@ -347,7 +352,8 @@ private Mono handleIncomingNotification(McpSchema.JSONRPCNotification noti */ private McpAsyncServerExchange copyExchange(McpAsyncServerExchange exchange, McpTransportContext transportContext) { return new McpAsyncServerExchange(exchange.sessionId(), this, exchange.getClientCapabilities(), - exchange.getClientInfo(), transportContext, this.jsonSchemaValidator); + exchange.getClientInfo(), transportContext, this.jsonSchemaValidator, + this.negotiatedProtocolVersion.get()); } record MethodNotFoundError(String method, String message, Object data) { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java index 5bb5c3812..32b29ac38 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java @@ -57,6 +57,8 @@ public class McpStreamableServerSession implements McpLoggableSession { private final AtomicReference clientInfo = new AtomicReference<>(); + private final AtomicReference negotiatedProtocolVersion = new AtomicReference<>(); + private final AtomicReference listeningStreamRef; private final MissingMcpTransportSession missingMcpTransportSession; @@ -150,6 +152,10 @@ public String getId() { return this.id; } + void setNegotiatedProtocolVersion(String version) { + this.negotiatedProtocolVersion.set(version); + } + private String generateRequestId() { return this.id + "-" + this.requestCounter.getAndIncrement(); } @@ -220,7 +226,8 @@ public Mono responseStream(McpSchema.JSONRPCRequest jsonrpcRequest, McpStr } return requestHandler .handle(new McpAsyncServerExchange(this.id, stream, clientCapabilities.get(), clientInfo.get(), - transportContext, this.jsonSchemaValidator), jsonrpcRequest.params()) + transportContext, this.jsonSchemaValidator, this.negotiatedProtocolVersion.get()), + jsonrpcRequest.params()) .map(result -> McpSchema.JSONRPCResponse.result(jsonrpcRequest.id(), result)) .onErrorResume(e -> { McpSchema.JSONRPCResponse.JSONRPCError jsonRpcError = (e instanceof McpError mcpError @@ -251,8 +258,8 @@ public Mono accept(McpSchema.JSONRPCNotification notification) { } McpLoggableSession listeningStream = this.listeningStreamRef.get(); return notificationHandler.handle(new McpAsyncServerExchange(this.id, listeningStream, - this.clientCapabilities.get(), this.clientInfo.get(), transportContext, this.jsonSchemaValidator), - notification.params()); + this.clientCapabilities.get(), this.clientInfo.get(), transportContext, this.jsonSchemaValidator, + this.negotiatedProtocolVersion.get()), notification.params()); }); } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java index 2f01bb06e..7bd634ad1 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java @@ -396,6 +396,150 @@ void testSamplingCreateMessageRequestHandlingWithNullHandler() { .hasMessage("Sampling handler must not be null when client capabilities include sampling"); } + // SEP-1577 — Sampling with Tools + + @Test + void testSamplingWithToolsHandlerCapabilityAdvertisement() { + MockMcpClientTransport transport = new MockMcpClientTransport(); + McpAsyncClient client = McpClient.async(transport) + .samplingWithTools(req -> Mono.just(McpSchema.CreateMessageWithToolsResult + .builder(McpSchema.Role.ASSISTANT, List.of(McpSchema.TextContent.builder("ok").build()), "m") + .build())) + .build(); + McpSchema.ClientCapabilities caps = client.getClientCapabilities(); + assertThat(caps.sampling()).isNotNull(); + assertThat(caps.sampling().tools()).isNotNull(); + } + + @Test + void testSamplingHandlerCapabilityDoesNotAdvertiseTools() { + MockMcpClientTransport transport = new MockMcpClientTransport(); + McpAsyncClient client = McpClient.async(transport) + .sampling(req -> Mono.just(McpSchema.CreateMessageResult + .builder(McpSchema.Role.ASSISTANT, req.messages().get(0).content(), "m") + .build())) + .build(); + McpSchema.ClientCapabilities caps = client.getClientCapabilities(); + assertThat(caps.sampling()).isNotNull(); + assertThat(caps.sampling().tools()).isNull(); + } + + /** + * Primitive end-to-end dynamic fulfillment test: the server-side test code drives the + * agentic loop manually around the client's sampling-with-tools handler. The handler + * is stateful: on turn 1 it returns a ToolUseContent + TOOL_USE stop reason; on turn + * 2 it sees the ToolResultContent and returns TextContent + END_TURN. + */ + @Test + void testSamplingWithToolsDynamicFulfillmentLoop() { + MockMcpClientTransport transport = initializationEnabledTransport(); + + List receivedRequests = new ArrayList<>(); + + // Stateful handler — turn 1 returns tool call, turn 2 returns final text + Function> handler = req -> { + receivedRequests.add(req); + McpSchema.SamplingMessageV2 lastMsg = req.messages().get(req.messages().size() - 1); + boolean hasToolResult = lastMsg.content().stream().anyMatch(c -> c instanceof McpSchema.ToolResultContent); + if (hasToolResult) { + // Turn 2: echo the tool result back + McpSchema.ToolResultContent toolResult = lastMsg.content() + .stream() + .filter(c -> c instanceof McpSchema.ToolResultContent) + .map(c -> (McpSchema.ToolResultContent) c) + .findFirst() + .get(); + String toolOutput = ((McpSchema.TextContent) toolResult.content().get(0)).text(); + return Mono.just(McpSchema.CreateMessageWithToolsResult + .builder(McpSchema.Role.ASSISTANT, + List.of(McpSchema.TextContent.builder("done: " + toolOutput).build()), "test-model") + .stopReason(McpSchema.CreateMessageResult.StopReason.END_TURN) + .build()); + } + else { + // Turn 1: request a tool call + return Mono + .just(McpSchema.CreateMessageWithToolsResult + .builder(McpSchema.Role.ASSISTANT, + List.of(McpSchema.ToolUseContent.builder("call_1", "echo") + .input(Map.of("text", "ping")) + .build()), + "test-model") + .stopReason(McpSchema.CreateMessageResult.StopReason.TOOL_USE) + .build()); + } + }; + + McpAsyncClient asyncMcpClient = McpClient.async(transport).samplingWithTools(handler).build(); + assertThat(asyncMcpClient.initialize().block()).isNotNull(); + + // --- Simulate server-side agentic loop --- + McpSchema.Tool echoTool = McpSchema.Tool.builder() + .name("echo") + .description("Echoes input") + .inputSchema(Map.of("type", "object")) + .build(); + + List messages = new ArrayList<>(); + messages.add( + McpSchema.SamplingMessageV2.of(McpSchema.Role.USER, McpSchema.TextContent.builder("say ping").build())); + + // --- Turn 1 --- + McpSchema.CreateMessageWithToolsRequest turn1Req = McpSchema.CreateMessageWithToolsRequest + .builder(new ArrayList<>(messages), 100) + .tools(List.of(echoTool)) + .toolChoice(McpSchema.ToolChoice.auto()) + .build(); + + McpSchema.JSONRPCRequest rpcReq1 = new McpSchema.JSONRPCRequest(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, + "id-1", turn1Req); + transport.simulateIncomingMessage(rpcReq1); + + McpSchema.JSONRPCResponse response1 = (McpSchema.JSONRPCResponse) transport.getLastSentMessage(); + assertThat(response1.error()).isNull(); + McpSchema.CreateMessageWithToolsResult result1 = transport.unmarshalFrom(response1.result(), + new TypeRef() { + }); + assertThat(result1.stopReason()).isEqualTo(McpSchema.CreateMessageResult.StopReason.TOOL_USE); + assertThat(result1.content().get(0)).isInstanceOf(McpSchema.ToolUseContent.class); + + // Server executes the tool + McpSchema.ToolUseContent toolUse = (McpSchema.ToolUseContent) result1.content().get(0); + String toolOutput = (String) toolUse.input().get("text"); // "ping" + + // Extend messages with assistant turn + tool result + messages.add(new McpSchema.SamplingMessageV2(McpSchema.Role.ASSISTANT, result1.content())); + messages.add(McpSchema.SamplingMessageV2.of(McpSchema.Role.USER, + McpSchema.ToolResultContent.builder(toolUse.id()) + .content(List.of(McpSchema.TextContent.builder(toolOutput).build())) + .build())); + + // --- Turn 2 --- + McpSchema.CreateMessageWithToolsRequest turn2Req = McpSchema.CreateMessageWithToolsRequest + .builder(new ArrayList<>(messages), 100) + .tools(List.of(echoTool)) + .toolChoice(McpSchema.ToolChoice.auto()) + .build(); + + McpSchema.JSONRPCRequest rpcReq2 = new McpSchema.JSONRPCRequest(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, + "id-2", turn2Req); + transport.simulateIncomingMessage(rpcReq2); + + McpSchema.JSONRPCResponse response2 = (McpSchema.JSONRPCResponse) transport.getLastSentMessage(); + assertThat(response2.error()).isNull(); + McpSchema.CreateMessageWithToolsResult result2 = transport.unmarshalFrom(response2.result(), + new TypeRef() { + }); + assertThat(result2.stopReason()).isEqualTo(McpSchema.CreateMessageResult.StopReason.END_TURN); + assertThat(result2.content().get(0)).isInstanceOf(McpSchema.TextContent.class); + assertThat(((McpSchema.TextContent) result2.content().get(0)).text()).isEqualTo("done: ping"); + + // Two turns were processed + assertThat(receivedRequests).hasSize(2); + + asyncMcpClient.closeGracefully(); + } + @Test @SuppressWarnings("unchecked") void testElicitationCreateRequestHandling() { diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index 6ac076559..73c95f734 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -63,7 +63,7 @@ void testContentDeserializationWrongType() { .extracting(throwable -> throwable.getCause() != null ? throwable.getCause() : throwable) .asInstanceOf(InstanceOfAssertFactories.THROWABLE) .hasMessageContaining( - "Could not resolve type id 'WRONG' as a subtype of `io.modelcontextprotocol.spec.McpSchema$TextContent`: known type ids = [audio, image, resource, resource_link, text]") + "Could not resolve type id 'WRONG' as a subtype of `io.modelcontextprotocol.spec.McpSchema$TextContent`") .extracting(Object::getClass) .extracting(Class::getSimpleName) // Class name is the same for both Jackson 2 and 3, only the package differs. @@ -1552,6 +1552,168 @@ void testCreateMessageResultUnknownStopReason() throws Exception { assertThat(value).isEqualTo(expected); } + // SEP-1577 Sampling with Tools Tests + + @Test + void testToolUseContentRoundTrip() throws Exception { + McpSchema.ToolUseContent content = McpSchema.ToolUseContent.builder("call_1", "echo") + .input(Map.of("text", "ping")) + .build(); + String json = JSON_MAPPER.writeValueAsString(content); + assertThatJson(json).isObject().isEqualTo(json(""" + {"type":"tool_use","id":"call_1","name":"echo","input":{"text":"ping"}}""")); + McpSchema.ToolUseContent roundTripped = JSON_MAPPER.readValue(json, McpSchema.ToolUseContent.class); + assertThat(roundTripped.id()).isEqualTo("call_1"); + assertThat(roundTripped.name()).isEqualTo("echo"); + assertThat(roundTripped.input()).containsEntry("text", "ping"); + } + + @Test + void testToolUseContentPolymorphic() throws Exception { + McpSchema.Content content = JSON_MAPPER.readValue(""" + {"type":"tool_use","id":"call_2","name":"calc","input":{"x":1}}""", McpSchema.Content.class); + assertThat(content).isInstanceOf(McpSchema.ToolUseContent.class); + assertThat(((McpSchema.ToolUseContent) content).id()).isEqualTo("call_2"); + } + + @Test + void testToolResultContentRoundTrip() throws Exception { + McpSchema.ToolResultContent result = McpSchema.ToolResultContent.builder("call_1") + .content(List.of(McpSchema.TextContent.builder("42").build())) + .isError(false) + .build(); + String json = JSON_MAPPER.writeValueAsString(result); + assertThatJson(json).isObject() + .isEqualTo( + json(""" + {"type":"tool_result","toolUseId":"call_1","content":[{"type":"text","text":"42"}],"isError":false}""")); + McpSchema.ToolResultContent roundTripped = JSON_MAPPER.readValue(json, McpSchema.ToolResultContent.class); + assertThat(roundTripped.toolUseId()).isEqualTo("call_1"); + assertThat(roundTripped.content()).hasSize(1); + assertThat(roundTripped.isError()).isFalse(); + } + + @Test + void testToolChoiceRoundTrip() throws Exception { + assertThatJson(JSON_MAPPER.writeValueAsString(McpSchema.ToolChoice.auto())).isObject() + .isEqualTo(json("{\"mode\":\"auto\"}")); + assertThatJson(JSON_MAPPER.writeValueAsString(McpSchema.ToolChoice.required())).isObject() + .isEqualTo(json("{\"mode\":\"required\"}")); + assertThatJson(JSON_MAPPER.writeValueAsString(McpSchema.ToolChoice.none())).isObject() + .isEqualTo(json("{\"mode\":\"none\"}")); + McpSchema.ToolChoice parsed = JSON_MAPPER.readValue("{\"mode\":\"auto\"}", McpSchema.ToolChoice.class); + assertThat(parsed.mode()).isEqualTo("auto"); + } + + @Test + void testSamplingMessageV2AcceptsSingleContentObject() throws Exception { + // Legacy wire format: bare object (not array) — must still deserialize + McpSchema.SamplingMessageV2 msg = JSON_MAPPER.readValue(""" + {"role":"user","content":{"type":"text","text":"hi"}}""", McpSchema.SamplingMessageV2.class); + assertThat(msg.role()).isEqualTo(McpSchema.Role.USER); + assertThat(msg.content()).hasSize(1); + assertThat(msg.content().get(0)).isInstanceOf(McpSchema.TextContent.class); + } + + @Test + void testSamplingMessageV2EmitsArrayOnWire() throws Exception { + McpSchema.SamplingMessageV2 msg = McpSchema.SamplingMessageV2 + .builder(McpSchema.Role.USER, McpSchema.TextContent.builder("hello").build()) + .build(); + String json = JSON_MAPPER.writeValueAsString(msg); + // content must be emitted as an array even for a single block + assertThatJson(json).isObject().node("content").isArray().hasSize(1); + } + + @Test + void testCreateMessageWithToolsRequestRoundTrip() throws Exception { + McpSchema.Tool echoTool = McpSchema.Tool.builder() + .name("echo") + .description("Echoes input") + .inputSchema(Map.of("type", "object")) + .build(); + McpSchema.CreateMessageWithToolsRequest request = McpSchema.CreateMessageWithToolsRequest + .builder(List.of(McpSchema.SamplingMessageV2.of(McpSchema.Role.USER, + McpSchema.TextContent.builder("say hi").build())), 100) + .tools(List.of(echoTool)) + .toolChoice(McpSchema.ToolChoice.auto()) + .build(); + String json = JSON_MAPPER.writeValueAsString(request); + assertThatJson(json).isObject().node("tools").isArray().hasSize(1); + assertThatJson(json).isObject().node("toolChoice").isObject().node("mode").isEqualTo("auto"); + McpSchema.CreateMessageWithToolsRequest roundTripped = JSON_MAPPER.readValue(json, + McpSchema.CreateMessageWithToolsRequest.class); + assertThat(roundTripped.tools()).hasSize(1); + assertThat(roundTripped.toolChoice().mode()).isEqualTo("auto"); + } + + @Test + void testCreateMessageWithToolsRequestLegacyBackwardCompat() throws Exception { + // Deserialize JSON without tools/toolChoice — must succeed with nulls + McpSchema.CreateMessageWithToolsRequest request = JSON_MAPPER.readValue(""" + {"messages":[{"role":"user","content":{"type":"text","text":"hi"}}],"maxTokens":100}""", + McpSchema.CreateMessageWithToolsRequest.class); + assertThat(request.tools()).isNull(); + assertThat(request.toolChoice()).isNull(); + // Serialize back — tools and toolChoice keys must be absent + String json = JSON_MAPPER.writeValueAsString(request); + assertThatJson(json).isObject().doesNotContainKey("tools").doesNotContainKey("toolChoice"); + } + + @Test + void testCreateMessageWithToolsResultRoundTrip() throws Exception { + McpSchema.CreateMessageWithToolsResult result = McpSchema.CreateMessageWithToolsResult + .builder(McpSchema.Role.ASSISTANT, + List.of(McpSchema.ToolUseContent.builder("call_1", "echo").input(Map.of("text", "ping")).build()), + "gpt-4") + .stopReason(McpSchema.CreateMessageResult.StopReason.TOOL_USE) + .build(); + String json = JSON_MAPPER.writeValueAsString(result); + assertThatJson(json).isObject().node("stopReason").isEqualTo("toolUse"); + assertThatJson(json).isObject().node("content").isArray().hasSize(1); + assertThatJson(json).isObject() + .node("content") + .isArray() + .element(0) + .isObject() + .node("type") + .isEqualTo("tool_use"); + McpSchema.CreateMessageWithToolsResult roundTripped = JSON_MAPPER.readValue(json, + McpSchema.CreateMessageWithToolsResult.class); + assertThat(roundTripped.stopReason()).isEqualTo(McpSchema.CreateMessageResult.StopReason.TOOL_USE); + assertThat(roundTripped.content().get(0)).isInstanceOf(McpSchema.ToolUseContent.class); + } + + @Test + void testStopReasonToolUse() throws Exception { + assertThat(McpSchema.CreateMessageResult.StopReason.of("toolUse")) + .isEqualTo(McpSchema.CreateMessageResult.StopReason.TOOL_USE); + String json = JSON_MAPPER.writeValueAsString(McpSchema.CreateMessageResult.StopReason.TOOL_USE); + assertThat(json).isEqualTo("\"toolUse\""); + } + + @Test + void testSamplingCapabilityWithTools() throws Exception { + McpSchema.ClientCapabilities caps = McpSchema.ClientCapabilities.builder().samplingTools().build(); + String json = JSON_MAPPER.writeValueAsString(caps); + assertThatJson(json).isObject().node("sampling").isObject().node("tools").isObject(); + McpSchema.ClientCapabilities roundTripped = JSON_MAPPER.readValue(json, McpSchema.ClientCapabilities.class); + assertThat(roundTripped.sampling()).isNotNull(); + assertThat(roundTripped.sampling().tools()).isNotNull(); + } + + @Test + void testSamplingCapabilityEmptyBackwardCompat() throws Exception { + McpSchema.ClientCapabilities caps = McpSchema.ClientCapabilities.builder().sampling().build(); + String json = JSON_MAPPER.writeValueAsString(caps); + // tools key must be absent when not set + assertThatJson(json).isObject().node("sampling").isObject(); + assertThat(json).doesNotContain("\"tools\""); + McpSchema.ClientCapabilities roundTripped = JSON_MAPPER.readValue(json, McpSchema.ClientCapabilities.class); + assertThat(roundTripped.sampling()).isNotNull(); + assertThat(roundTripped.sampling().tools()).isNull(); + } + // Elicitation Tests @Test