Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, ?> 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<String, Object> tool(String name) {
return Map.of("name", name, "inputSchema", Map.of("type", "object", "properties", Map.of()));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<JsonRpcRequest> notificationWriter,
Consumer<JsonRpcResponse> responseWriter
Expand Down Expand Up @@ -210,6 +220,7 @@ public static final class Builder {
private ToolFilter toolFilter = (serverId, toolName) -> true;
private McpMetricsObserver metricsObserver;
private boolean discoverProtocols = true;
private Set<McpProtocolId> protocolVersions;
private McpCachePolicy cachePolicy = McpCachePolicy.DEFAULT;

public Builder services(Map<String, Service> services) {
Expand Down Expand Up @@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>{@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<McpProtocolId>();
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -58,12 +59,19 @@ static McpProtocolRegistry create(
Collection<? extends ExtensionMcpProtocol> 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<? extends ExtensionMcpProtocol> additions,
Collection<? extends ExtensionMcpProtocol> overrides,
boolean discover,
Set<McpProtocolId> allowed
) {
Iterable<McpProtocolProvider> providers = discover
? ServiceLoader.load(McpProtocolProvider.class, McpProtocolProvider.class.getClassLoader())
: List.of();
return create(additions, overrides, providers, allowed);
}

static McpProtocolRegistry create(
Expand All @@ -81,6 +89,21 @@ static McpProtocolRegistry create(
Collection<? extends ExtensionMcpProtocol> additions,
Collection<? extends ExtensionMcpProtocol> overrides,
Iterable<McpProtocolProvider> 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<? extends ExtensionMcpProtocol> additions,
Collection<? extends ExtensionMcpProtocol> overrides,
Iterable<McpProtocolProvider> providers,
Set<McpProtocolId> allowed
) {
var candidates = new LinkedHashMap<McpProtocolId, List<Candidate>>();
for (var protocol : BuiltInProtocols.all()) {
Expand Down Expand Up @@ -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);
}

Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
Loading
Loading