diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index fc1f8a47c..91f973f18 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -371,7 +371,7 @@ public Mono addTool(McpServerFeatures.AsyncToolSpecification toolSpecifica this.tools.add(wrappedToolSpecification); logger.debug("Added tool handler: {}", wrappedToolSpecification.tool().name()); - if (this.serverCapabilities.tools().listChanged()) { + if (Boolean.TRUE.equals(this.serverCapabilities.tools().listChanged())) { return notifyToolsListChanged(); } return Mono.empty(); @@ -519,7 +519,7 @@ public Mono removeTool(String toolName) { return Mono.defer(() -> { if (this.tools.removeIf(toolSpecification -> toolSpecification.tool().name().equals(toolName))) { logger.debug("Removed tool handler: {}", toolName); - if (this.serverCapabilities.tools().listChanged()) { + if (Boolean.TRUE.equals(this.serverCapabilities.tools().listChanged())) { return notifyToolsListChanged(); } } @@ -622,7 +622,7 @@ public Mono addResource(McpServerFeatures.AsyncResourceSpecification resou else { logger.debug("Added resource handler: {}", resourceSpecification.resource().uri()); } - if (this.serverCapabilities.resources().listChanged()) { + if (Boolean.TRUE.equals(this.serverCapabilities.resources().listChanged())) { return notifyResourcesListChanged(); } return Mono.empty(); @@ -655,7 +655,7 @@ public Mono removeResource(String resourceUri) { McpServerFeatures.AsyncResourceSpecification removed = this.resources.remove(resourceUri); if (removed != null) { logger.debug("Removed resource handler: {}", resourceUri); - if (this.serverCapabilities.resources().listChanged()) { + if (Boolean.TRUE.equals(this.serverCapabilities.resources().listChanged())) { return notifyResourcesListChanged(); } return Mono.empty(); @@ -691,7 +691,7 @@ public Mono addResourceTemplate( logger.debug("Added resource template handler: {}", resourceTemplateSpecification.resourceTemplate().uriTemplate()); } - if (this.serverCapabilities.resources().listChanged()) { + if (Boolean.TRUE.equals(this.serverCapabilities.resources().listChanged())) { return notifyResourcesListChanged(); } return Mono.empty(); @@ -891,7 +891,7 @@ public Mono addPrompt(McpServerFeatures.AsyncPromptSpecification promptSpe else { logger.debug("Added prompt handler: {}", promptSpecification.prompt().name()); } - if (this.serverCapabilities.prompts().listChanged()) { + if (Boolean.TRUE.equals(this.serverCapabilities.prompts().listChanged())) { return this.notifyPromptsListChanged(); } @@ -925,7 +925,7 @@ public Mono removePrompt(String promptName) { if (removed != null) { logger.debug("Removed prompt handler: {}", promptName); - if (this.serverCapabilities.prompts().listChanged()) { + if (Boolean.TRUE.equals(this.serverCapabilities.prompts().listChanged())) { return this.notifyPromptsListChanged(); } return Mono.empty(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/DerivedCapabilitiesRuntimeChangesTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/DerivedCapabilitiesRuntimeChangesTests.java new file mode 100644 index 000000000..17d164ad6 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/DerivedCapabilitiesRuntimeChangesTests.java @@ -0,0 +1,118 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server; + +import io.modelcontextprotocol.MockMcpServerTransport; +import io.modelcontextprotocol.MockMcpServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Runtime add/remove operations on a server whose capabilities were derived from the + * features registered at build time (no explicit {@code capabilities(...)}). The derived + * capabilities leave {@code listChanged} unset, so no list_changed notification is + * expected and the operations must complete normally. + */ +class DerivedCapabilitiesRuntimeChangesTests { + + private static final McpSchema.Implementation SERVER_INFO = McpSchema.Implementation.builder("test-server", "1.0.0") + .build(); + + private static McpServerFeatures.AsyncToolSpecification asyncTool(String name) { + return McpServerFeatures.AsyncToolSpecification.builder() + .tool(McpSchema.Tool.builder(name).description(name).build()) + .callHandler((exchange, request) -> Mono.just(McpSchema.CallToolResult.builder().build())) + .build(); + } + + private static McpServerFeatures.AsyncResourceSpecification asyncResource(String uri) { + return new McpServerFeatures.AsyncResourceSpecification(McpSchema.Resource.builder(uri, uri).build(), + (exchange, request) -> Mono.empty()); + } + + private static McpServerFeatures.AsyncResourceTemplateSpecification asyncTemplate(String uriTemplate) { + return new McpServerFeatures.AsyncResourceTemplateSpecification( + McpSchema.ResourceTemplate.builder(uriTemplate, uriTemplate).build(), + (exchange, request) -> Mono.empty()); + } + + private static McpServerFeatures.AsyncPromptSpecification asyncPrompt(String name) { + return new McpServerFeatures.AsyncPromptSpecification(McpSchema.Prompt.builder(name).build(), + (exchange, request) -> Mono.empty()); + } + + @Test + void addAndRemoveToolWithDerivedCapabilities() { + MockMcpServerTransport transport = new MockMcpServerTransport(); + McpAsyncServer server = McpServer.async(new MockMcpServerTransportProvider(transport)) + .serverInfo(SERVER_INFO) + .tools(asyncTool("initial")) + .build(); + + assertThat(server.getServerCapabilities().tools().listChanged()).isNull(); + + StepVerifier.create(server.addTool(asyncTool("added"))).verifyComplete(); + StepVerifier.create(server.removeTool("initial")).verifyComplete(); + + assertThat(server.listTools().map(McpSchema.Tool::name).collectList().block()).containsExactly("added"); + assertThat(transport.getAllSentMessages()).isEmpty(); + } + + @Test + void addAndRemoveResourceAndTemplateWithDerivedCapabilities() { + MockMcpServerTransport transport = new MockMcpServerTransport(); + McpAsyncServer server = McpServer.async(new MockMcpServerTransportProvider(transport)) + .serverInfo(SERVER_INFO) + .resources(asyncResource("test://initial")) + .build(); + + assertThat(server.getServerCapabilities().resources().listChanged()).isNull(); + + StepVerifier.create(server.addResource(asyncResource("test://added"))).verifyComplete(); + StepVerifier.create(server.removeResource("test://initial")).verifyComplete(); + StepVerifier.create(server.addResourceTemplate(asyncTemplate("test://items/{id}"))).verifyComplete(); + + assertThat(transport.getAllSentMessages()).isEmpty(); + } + + @Test + void addAndRemovePromptWithDerivedCapabilities() { + MockMcpServerTransport transport = new MockMcpServerTransport(); + McpAsyncServer server = McpServer.async(new MockMcpServerTransportProvider(transport)) + .serverInfo(SERVER_INFO) + .prompts(asyncPrompt("initial")) + .build(); + + assertThat(server.getServerCapabilities().prompts().listChanged()).isNull(); + + StepVerifier.create(server.addPrompt(asyncPrompt("added"))).verifyComplete(); + StepVerifier.create(server.removePrompt("initial")).verifyComplete(); + + assertThat(transport.getAllSentMessages()).isEmpty(); + } + + @Test + void syncServerAddAndRemoveToolWithDerivedCapabilities() { + McpSyncServer server = McpServer.sync(new MockMcpServerTransportProvider(new MockMcpServerTransport())) + .serverInfo(SERVER_INFO) + .tools(McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder("initial").description("initial").build()) + .callHandler((exchange, request) -> McpSchema.CallToolResult.builder().build()) + .build()) + .build(); + + assertThatCode(() -> server.addTool(McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder("added").description("added").build()) + .callHandler((exchange, request) -> McpSchema.CallToolResult.builder().build()) + .build())).doesNotThrowAnyException(); + assertThatCode(() -> server.removeTool("initial")).doesNotThrowAnyException(); + } + +}