From c42f4b06bdd04649422f128af3f58aedf957e8b7 Mon Sep 17 00:00:00 2001 From: Param Parikh <14313811+psp65@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:17:10 +0000 Subject: [PATCH] Add option to restrict the MCP protocol versions a server serves A server that negotiates 2026-07-28 cannot deliver notifications/tools/list_changed: that revision only delivers list-change notifications on a subscriptions/listen stream, which is not implemented, so server/discover rightly omits listChanged. A stdio server that relays list changes from proxied servers (dynamic tool discovery) therefore stops working for clients that prefer 2026-07-28, even though the same clients work over the handshake versions. Add McpEngine.Builder#protocolVersions and the matching StdioMcpServerBuilder option. When set, only the given registered versions are negotiated and advertised: server/discover and -32022 errors list only them, and initialize falls back to the newest retained handshake version. Clients that support both eras then fall back to initialize and keep receiving list-change notifications. The default protocol used for requests that do not identify a version now falls back to the initialize fallback when the default version is excluded, for stdio sessions and for HTTP requests without a version header. The default behavior is unchanged. --- ...oteCatalogNotificationIntegrationTest.java | 138 ++++++++++++++++++ .../smithy/java/mcp/server/McpEngine.java | 47 +++++- .../java/mcp/server/McpHttpHandler.java | 4 +- .../java/mcp/server/McpProtocolRegistry.java | 48 +++++- .../mcp/server/StdioMcpServerBuilder.java | 17 ++- .../java/mcp/server/McpArchitectureTest.java | 100 +++++++++++++ .../java/mcp/server/McpHttpHandlerTest.java | 16 ++ .../java/mcp/server/StdioMcpServerTest.java | 60 ++++++++ 8 files changed, 419 insertions(+), 11 deletions(-) diff --git a/mcp/mcp-server/src/it/java/software/amazon/smithy/java/mcp/server/RemoteCatalogNotificationIntegrationTest.java b/mcp/mcp-server/src/it/java/software/amazon/smithy/java/mcp/server/RemoteCatalogNotificationIntegrationTest.java index b4bc38de1..593f3ffbf 100644 --- a/mcp/mcp-server/src/it/java/software/amazon/smithy/java/mcp/server/RemoteCatalogNotificationIntegrationTest.java +++ b/mcp/mcp-server/src/it/java/software/amazon/smithy/java/mcp/server/RemoteCatalogNotificationIntegrationTest.java @@ -24,6 +24,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import software.amazon.smithy.java.core.serde.document.Document; @@ -192,6 +193,143 @@ void stdioListingAfterHttpNotificationWaitsForFreshCatalog(boolean failRefresh) } } + @Test + void handshakeOnlyStdioServerKeepsDeliveringListChangedToStatelessCapableClients() throws Exception { + var discovered = new AtomicBoolean(); + var http = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + http.setExecutor(executor); + http.createContext("/mcp", exchange -> { + try (exchange) { + var request = McpJson.CODEC.deserializeShape( + exchange.getRequestBody().readAllBytes(), + JsonRpcRequest.builder()); + if (request.getId() == null) { + exchange.sendResponseHeaders(202, -1); + return; + } + Map result; + boolean sendNotification = false; + switch (request.getMethod()) { + case "initialize" -> result = Map.of( + "protocolVersion", + "2025-06-18", + "capabilities", + Map.of("tools", Map.of("listChanged", true)), + "serverInfo", + Map.of("name", "discovery-server", "version", "1")); + case "tools/list" -> result = Map.of("tools", + discovered.get() + ? List.of(tool("discover"), tool("new_tool")) + : List.of(tool("discover"))); + case "prompts/list" -> result = Map.of("prompts", List.of()); + case "tools/call" -> { + sendNotification = request.getParams().getMember("name").asString().equals("discover"); + if (sendNotification) { + discovered.set(true); + } + result = Map.of("content", List.of(Map.of("type", "text", "text", "success"))); + } + default -> throw new IOException("Unexpected method: " + request.getMethod()); + } + var response = McpJson.CODEC.serializeToString(JsonRpcResponse.builder() + .jsonrpc("2.0") + .id(request.getId()) + .result(Document.ofObject(result)) + .build()); + if (sendNotification) { + respond(exchange, + "text/event-stream", + "data: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\"}\n\n" + + "data: " + response + "\n\n"); + } else { + respond(exchange, "application/json", response); + } + } + }); + http.start(); + + var input = new TestInputStream(); + var output = new TestOutputStream(); + var remote = HttpMcpClient.builder() + .endpoint("http://127.0.0.1:" + http.getAddress().getPort() + "/mcp") + .build(); + var server = StdioMcpServer.builder() + .addRemoteClient(remote) + .protocolVersions( + KnownProtocolVersion.V2025_11_25, + KnownProtocolVersion.V2025_06_18, + KnownProtocolVersion.V2025_03_26, + KnownProtocolVersion.V2024_11_05) + .input(input) + .output(output) + .build(); + server.start(); + try { + // A client that prefers 2026-07-28 probes first, learns the server only speaks the handshake + // versions, and initializes instead. + send(input, + 1, + "server/discover", + Map.of("_meta", + Map.of( + "io.modelcontextprotocol/protocolVersion", + "2026-07-28", + "io.modelcontextprotocol/clientCapabilities", + Map.of(), + "io.modelcontextprotocol/clientInfo", + Map.of("name", "test", "version", "1")))); + var probe = read(output); + assertEquals(1, probe.getMember("id").asInteger()); + assertEquals(-32022, probe.getMember("error").getMember("code").asInteger()); + + send(input, + 2, + "initialize", + Map.of( + "protocolVersion", + "2026-07-28", + "capabilities", + Map.of(), + "clientInfo", + Map.of("name", "test", "version", "1"))); + var initialize = assertResponse(read(output), 2); + assertEquals("2025-11-25", initialize.getMember("protocolVersion").asString()); + assertTrue(initialize.getMember("capabilities") + .getMember("tools") + .getMember("listChanged") + .asBoolean()); + send(input, null, "notifications/initialized", Map.of()); + + send(input, 3, "tools/list", Map.of()); + assertEquals(List.of("discover"), toolNames(assertResponse(read(output), 3))); + + send(input, 4, "tools/call", Map.of("name", "discover", "arguments", Map.of())); + var notification = read(output); + assertEquals("notifications/tools/list_changed", notification.getMember("method").asString()); + assertNull(notification.getMember("id")); + assertResponse(read(output), 4); + + send(input, 5, "tools/list", Map.of()); + assertEquals(List.of("discover", "new_tool"), toolNames(assertResponse(read(output), 5))); + send(input, 6, "tools/call", Map.of("name", "new_tool", "arguments", Map.of())); + assertEquals("success", + assertResponse(read(output), 6) + .getMember("content") + .asList() + .getFirst() + .getMember("text") + .asString()); + output.assertNoOutput(100); + } finally { + server.shutdown().join(); + } + } finally { + http.stop(0); + } + } + private static Map tool(String name) { return Map.of("name", name, "inputSchema", Map.of("type", "object", "properties", Map.of())); } diff --git a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpEngine.java b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpEngine.java index 22d978a6a..f8c0da610 100644 --- a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpEngine.java +++ b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpEngine.java @@ -8,9 +8,11 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.function.Consumer; import software.amazon.smithy.java.context.Context; import software.amazon.smithy.java.core.serde.document.Document; @@ -44,7 +46,8 @@ private McpEngine(Builder builder) { protocols = McpProtocolRegistry.create( builder.protocols.values(), builder.protocolOverrides.values(), - builder.discoverProtocols); + builder.discoverProtocols, + builder.protocolVersions); wireCodec = new McpWireCodec(builder.extensions); interceptor = builder.interceptor; cachePolicy = builder.cachePolicy; @@ -162,6 +165,13 @@ McpProtocol findProtocol(ProtocolVersion version) { return protocols.find(version); } + /** + * The version used when a request does not identify one, honoring any {@link Builder#protocolVersions} restriction. + */ + ProtocolVersion defaultProtocolVersion() { + return protocols.defaultProtocol().protocolVersion(); + } + void bindTransport( Consumer notificationWriter, Consumer responseWriter @@ -210,6 +220,7 @@ public static final class Builder { private ToolFilter toolFilter = (serverId, toolName) -> true; private McpMetricsObserver metricsObserver; private boolean discoverProtocols = true; + private Set protocolVersions; private McpCachePolicy cachePolicy = McpCachePolicy.DEFAULT; public Builder services(Map services) { @@ -260,6 +271,40 @@ public Builder discoverProtocols(boolean discoverProtocols) { return this; } + /** + * Restricts the protocol versions the engine negotiates and advertises to the given registered versions. + * + *

By default every registered protocol is served. When restricted, requests that claim another + * version fail with {@code -32022} (whose {@code supported} list, like {@code server/discover}'s + * {@code supportedVersions}, names only the retained versions), and {@code initialize} falls back to + * the newest retained handshake version. + * + *

For example, a server that relays {@code notifications/tools/list_changed} from proxied servers + * can be restricted to handshake versions (2025-11-25 and earlier). 2026-07-28 only delivers + * list-change notifications through {@code subscriptions/listen}, which is not implemented yet, so + * restricting the versions lets clients that support both fall back to {@code initialize} and keep + * receiving those notifications. + * + *

{@link #build()} fails with {@link IllegalArgumentException} if a version is not registered, and + * with {@link IllegalStateException} if none of the versions supports {@code initialize}. + * + * @param versions protocol versions to serve + * @return the builder + */ + public Builder protocolVersions(ProtocolVersion... versions) { + Objects.requireNonNull(versions, "versions"); + if (versions.length == 0) { + throw new IllegalArgumentException("At least one MCP protocol version is required"); + } + var ids = new LinkedHashSet(); + for (var protocolVersion : versions) { + Objects.requireNonNull(protocolVersion, "protocol version"); + ids.add(McpProtocolId.of(protocolVersion.identifier())); + } + this.protocolVersions = Set.copyOf(ids); + return this; + } + public Builder name(String name) { this.name = Objects.requireNonNull(name, "name"); return this; diff --git a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpHttpHandler.java b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpHttpHandler.java index 1fe0b991d..14d21bd9e 100644 --- a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpHttpHandler.java +++ b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpHttpHandler.java @@ -110,11 +110,11 @@ private ProtocolVersion resolveProtocolVersion( var params = request.getParams(); var identifier = McpHttpBinding.stringMember(params, "protocolVersion"); return identifier == null - ? ProtocolVersion.defaultVersion() + ? engine.defaultProtocolVersion() : ProtocolVersion.parse(identifier); } return headerVersion == null - ? ProtocolVersion.defaultVersion() + ? engine.defaultProtocolVersion() : ProtocolVersion.parse(headerVersion); } diff --git a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpProtocolRegistry.java b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpProtocolRegistry.java index 16e88cd0d..d34f077ec 100644 --- a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpProtocolRegistry.java +++ b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpProtocolRegistry.java @@ -14,6 +14,7 @@ import java.util.Map; import java.util.Objects; import java.util.ServiceLoader; +import java.util.Set; import software.amazon.smithy.java.core.serde.document.Document; /** @@ -58,12 +59,19 @@ static McpProtocolRegistry create( Collection overrides, boolean discover ) { - return discover - ? create( - additions, - overrides, - McpProtocolProvider.class.getClassLoader()) - : create(additions, overrides, List.of()); + return create(additions, overrides, discover, null); + } + + static McpProtocolRegistry create( + Collection additions, + Collection overrides, + boolean discover, + Set allowed + ) { + Iterable providers = discover + ? ServiceLoader.load(McpProtocolProvider.class, McpProtocolProvider.class.getClassLoader()) + : List.of(); + return create(additions, overrides, providers, allowed); } static McpProtocolRegistry create( @@ -81,6 +89,21 @@ static McpProtocolRegistry create( Collection additions, Collection overrides, Iterable providers + ) { + return create(additions, overrides, providers, null); + } + + /** + * Creates a registry, optionally restricted to the given protocol ids. + * + * @param allowed protocol ids to retain, in addition to validating that each one is registered; + * {@code null} retains every registered protocol + */ + static McpProtocolRegistry create( + Collection additions, + Collection overrides, + Iterable providers, + Set allowed ) { var candidates = new LinkedHashMap>(); for (var protocol : BuiltInProtocols.all()) { @@ -131,6 +154,15 @@ static McpProtocolRegistry create( throw new IllegalArgumentException( "Cannot override unregistered MCP protocol: " + id.identifier()); } + if (allowed != null) { + for (var id : allowed) { + if (!resolved.containsKey(id)) { + throw new IllegalArgumentException( + "Cannot restrict to unregistered MCP protocol: " + id.identifier()); + } + } + resolved.keySet().retainAll(allowed); + } return new McpProtocolRegistry(resolved); } @@ -156,7 +188,9 @@ McpProtocol find(ProtocolVersion version) { } McpProtocol defaultProtocol() { - return protocols.get(ProtocolVersion.defaultVersion().id()); + var protocol = protocols.get(ProtocolVersion.defaultVersion().id()); + // A registry restricted to a subset of versions may exclude the default version. + return protocol != null ? protocol : initializationFallbackProtocol; } McpProtocol initializationFallbackProtocol() { diff --git a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/StdioMcpServerBuilder.java b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/StdioMcpServerBuilder.java index 419c78f13..5cc04217b 100644 --- a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/StdioMcpServerBuilder.java +++ b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/StdioMcpServerBuilder.java @@ -33,6 +33,7 @@ public final class StdioMcpServerBuilder { private ToolFilter toolFilter = (server, tool) -> true; private McpMetricsObserver metricsObserver; private boolean discoverProtocols = true; + private ProtocolVersion[] protocolVersions; private McpCachePolicy cachePolicy = McpCachePolicy.DEFAULT; StdioMcpServerBuilder() {} @@ -116,6 +117,16 @@ public StdioMcpServerBuilder discoverProtocols(boolean discoverProtocols) { return this; } + /** + * Restricts the protocol versions the server negotiates and advertises. + * + * @see McpEngine.Builder#protocolVersions(ProtocolVersion...) + */ + public StdioMcpServerBuilder protocolVersions(ProtocolVersion... versions) { + this.protocolVersions = Objects.requireNonNull(versions, "versions").clone(); + return this; + } + public StdioMcpServerBuilder cachePolicy(McpCachePolicy cachePolicy) { this.cachePolicy = Objects.requireNonNull(cachePolicy, "cachePolicy"); return this; @@ -141,11 +152,15 @@ public StdioMcpServer build() { .discoverProtocols(discoverProtocols); protocols.values().forEach(engineBuilder::addProtocol); protocolOverrides.values().forEach(engineBuilder::overrideProtocol); + if (protocolVersions != null) { + engineBuilder.protocolVersions(protocolVersions); + } engine = engineBuilder.build(); } else if (!services.isEmpty() || !remoteClients.isEmpty() || !protocols.isEmpty() - || !protocolOverrides.isEmpty()) { + || !protocolOverrides.isEmpty() + || protocolVersions != null) { throw new IllegalStateException("Cannot combine a prebuilt engine with builder-managed sources"); } return new StdioMcpServer(this); diff --git a/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpArchitectureTest.java b/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpArchitectureTest.java index 1c01ae4bd..9bc977d83 100644 --- a/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpArchitectureTest.java +++ b/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpArchitectureTest.java @@ -548,6 +548,106 @@ private Document statelessParams() { Document.of(Map.of()))))); } + @Test + void restrictedProtocolVersionsLimitNegotiationAndAdvertisement() { + try (var engine = McpEngine.builder() + .protocolVersions(KnownProtocolVersion.V2025_11_25, KnownProtocolVersion.V2025_06_18) + .build()) { + var discover = JsonRpcRequest.builder() + .jsonrpc("2.0") + .id(Document.of(1)) + .method(McpMethod.Standard.SERVER_DISCOVER.wireName()) + .build(); + + var response = engine.execute(discover, KnownProtocolVersion.V2026_07_28); + + assertEquals(-32022, response.getError().getCode()); + assertEquals( + List.of( + KnownProtocolVersion.V2025_11_25.identifier(), + KnownProtocolVersion.V2025_06_18.identifier()), + response.getError() + .getData() + .getMember("supported") + .asList() + .stream() + .map(Document::asString) + .toList()); + + for (var requested : List.of(KnownProtocolVersion.V2026_07_28, KnownProtocolVersion.V2025_03_26)) { + var initialize = engine.execute(initializeRequest(requested), null); + + assertNull(initialize.getError()); + assertEquals( + KnownProtocolVersion.V2025_11_25.identifier(), + initialize.getResult().getMember("protocolVersion").asString()); + } + assertEquals( + KnownProtocolVersion.V2025_06_18.identifier(), + engine.execute(initializeRequest(KnownProtocolVersion.V2025_06_18), null) + .getResult() + .getMember("protocolVersion") + .asString()); + } + } + + @Test + void restrictedProtocolVersionsWithoutTheDefaultServeUnversionedRequests() { + try (var engine = McpEngine.builder().protocolVersions(KnownProtocolVersion.V2025_11_25).build()) { + var listTools = JsonRpcRequest.builder() + .jsonrpc("2.0") + .id(Document.of(1)) + .method(McpMethod.Standard.TOOLS_LIST.wireName()) + .build(); + + var response = engine.execute(listTools, null); + + assertNull(response.getError()); + assertSame(KnownProtocolVersion.V2025_11_25, engine.defaultProtocolVersion()); + } + } + + @Test + void restrictedProtocolVersionsCanRetainAnExtensionProtocol() { + var protocol = protocol( + "2099-01-01", + Set.of(McpMethod.Standard.INITIALIZE), + 100); + try (var engine = McpEngine.builder() + .discoverProtocols(false) + .addProtocol(protocol) + .protocolVersions(ProtocolVersion.parse(protocol.id().identifier())) + .build()) { + var response = engine.execute(initializeRequest(KnownProtocolVersion.V2025_11_25), null); + + assertNull(response.getError()); + assertEquals( + protocol.id().identifier(), + response.getResult().getMember("protocolVersion").asString()); + } + } + + @Test + void restrictingToAnUnregisteredProtocolVersionFailsConstruction() { + var builder = McpEngine.builder().protocolVersions(ProtocolVersion.parse("2099-01-01")); + + var error = assertThrows(IllegalArgumentException.class, builder::build); + + assertTrue(error.getMessage().contains("2099-01-01"), error.getMessage()); + } + + @Test + void restrictedProtocolVersionsMustRetainAnInitializationCapableVersion() { + var builder = McpEngine.builder().protocolVersions(KnownProtocolVersion.V2026_07_28); + + assertThrows(IllegalStateException.class, builder::build); + } + + @Test + void protocolVersionsRequiresAtLeastOneVersion() { + assertThrows(IllegalArgumentException.class, () -> McpEngine.builder().protocolVersions()); + } + private JsonRpcRequest initializeRequest(ProtocolVersion requestedVersion) { return JsonRpcRequest.builder() .jsonrpc("2.0") diff --git a/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpHttpHandlerTest.java b/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpHttpHandlerTest.java index 97c7dfef8..a78d8c8a2 100644 --- a/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpHttpHandlerTest.java +++ b/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpHttpHandlerTest.java @@ -155,6 +155,22 @@ void protocolNegotiationIsScopedToEachHttpRequest() { } } + @Test + void unversionedRequestsUseTheRestrictedDefaultVersion() { + try (var engine = McpEngine.builder().protocolVersions(KnownProtocolVersion.V2025_11_25).build()) { + var listTools = JsonRpcRequest.builder() + .jsonrpc("2.0") + .id(Document.of(1)) + .method(McpMethod.Standard.TOOLS_LIST.wireName()) + .build(); + + var response = new McpHttpHandler(engine).handle(listTools, Map.of()); + + assertEquals(200, response.statusCode()); + assertNull(response.body().getError()); + } + } + @Test void httpInitializeDoesNotAdvertiseUnavailableCapabilities() { try (var engine = McpEngine.builder().build()) { diff --git a/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/StdioMcpServerTest.java b/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/StdioMcpServerTest.java index f6712e95c..7f9da7d91 100644 --- a/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/StdioMcpServerTest.java +++ b/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/StdioMcpServerTest.java @@ -9,6 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -235,6 +236,65 @@ public void doesNotAdvertiseStubUtilities() { assertFalse(completion.getMember("hasMore").asBoolean()); } + @Test + public void handshakeOnlyServerSendsStatelessClientsBackToInitialize() { + server = StdioMcpServer.builder() + .name("smithy-mcp-server") + .input(input) + .output(output) + .protocolVersions( + KnownProtocolVersion.V2025_11_25, + KnownProtocolVersion.V2025_06_18, + KnownProtocolVersion.V2025_03_26, + KnownProtocolVersion.V2024_11_05) + .addService("test-mcp", + ProxyService.builder() + .service(ShapeId.from("smithy.test#TestService")) + .proxyEndpoint("http://localhost") + .model(MODEL) + .build()) + .build(); + + server.start(); + + write("server/discover", modernParams(Map.of())); + var discover = read(); + assertNull(discover.getResult()); + assertEquals(-32022, discover.getError().getCode()); + var supported = discover.getError() + .getData() + .getMember("supported") + .asList() + .stream() + .map(Document::asString) + .toList(); + assertEquals(KnownProtocolVersion.V2025_11_25.identifier(), supported.getFirst()); + assertFalse(supported.contains(KnownProtocolVersion.V2026_07_28.identifier())); + + write("initialize", + Document.of(Map.of( + "protocolVersion", + Document.of(KnownProtocolVersion.V2026_07_28.identifier())))); + var initialize = read().getResult(); + assertEquals( + KnownProtocolVersion.V2025_11_25.identifier(), + initialize.getMember("protocolVersion").asString()); + assertTrue(initialize.getMember("capabilities").getMember("tools").getMember("listChanged").asBoolean()); + } + + @Test + public void prebuiltEngineCannotBeCombinedWithProtocolVersions() { + try (var engine = McpEngine.builder().build()) { + var builder = StdioMcpServer.builder() + .input(input) + .output(output) + .engine(engine) + .protocolVersions(KnownProtocolVersion.V2025_11_25); + + assertThrows(IllegalStateException.class, builder::build); + } + } + @Test public void supportsV2026_07_28StatelessProtocol() { server = StdioMcpServer.builder()