From ea138d321d3e89856456914757f581f6ef58bfee Mon Sep 17 00:00:00 2001 From: YunKui Lu Date: Tue, 16 Sep 2025 00:41:30 +0800 Subject: [PATCH] fix: stateless mcp server registration tools failed - Fix StatelessServerSpecificationFactoryAutoConfiguration.toolSpecs method signature to use McpStatelessServerFeatures.SyncToolSpecification - Add StatelessServerSpecificationFactoryAutoConfiguration to spring imports - update McpStatelessServerAutoConfigurationIT and McpServerAutoConfigurationIT.java Signed-off-by: YunKui Lu --- ...SpecificationFactoryAutoConfiguration.java | 5 +- ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../McpServerAutoConfigurationIT.java | 153 +++++++++++++++++ ...McpStatelessServerAutoConfigurationIT.java | 154 ++++++++++++++++++ 4 files changed, 310 insertions(+), 3 deletions(-) diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/annotations/StatelessServerSpecificationFactoryAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/annotations/StatelessServerSpecificationFactoryAutoConfiguration.java index 8c28de45386..47290ed653c 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/annotations/StatelessServerSpecificationFactoryAutoConfiguration.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/annotations/StatelessServerSpecificationFactoryAutoConfiguration.java @@ -18,7 +18,6 @@ import java.util.List; -import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpStatelessServerFeatures; import org.springaicommunity.mcp.annotation.McpComplete; import org.springaicommunity.mcp.annotation.McpPrompt; @@ -76,10 +75,10 @@ public List completionSp } @Bean - public List toolSpecs( + public List toolSpecs( ServerMcpAnnotatedBeans beansWithMcpMethodAnnotations) { return SyncMcpAnnotationProviders - .toolSpecifications(beansWithMcpMethodAnnotations.getBeansByAnnotation(McpTool.class)); + .statelessToolSpecifications(beansWithMcpMethodAnnotations.getBeansByAnnotation(McpTool.class)); } } diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 1987378c3dd..96f267fd7b1 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -18,4 +18,5 @@ org.springframework.ai.mcp.server.common.autoconfigure.ToolCallbackConverterAuto org.springframework.ai.mcp.server.common.autoconfigure.McpServerStatelessAutoConfiguration org.springframework.ai.mcp.server.common.autoconfigure.StatelessToolCallbackConverterAutoConfiguration org.springframework.ai.mcp.server.common.autoconfigure.annotations.McpServerSpecificationFactoryAutoConfiguration +org.springframework.ai.mcp.server.common.autoconfigure.annotations.StatelessServerSpecificationFactoryAutoConfiguration org.springframework.ai.mcp.server.common.autoconfigure.annotations.McpServerAnnotationScannerAutoConfiguration diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/test/java/org/springframework/ai/mcp/server/common/autoconfigure/McpServerAutoConfigurationIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/test/java/org/springframework/ai/mcp/server/common/autoconfigure/McpServerAutoConfigurationIT.java index 0ce399003d1..2ff5a8a7b34 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/test/java/org/springframework/ai/mcp/server/common/autoconfigure/McpServerAutoConfigurationIT.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/test/java/org/springframework/ai/mcp/server/common/autoconfigure/McpServerAutoConfigurationIT.java @@ -17,8 +17,11 @@ package org.springframework.ai.mcp.server.common.autoconfigure; import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.BiConsumer; import java.util.function.BiFunction; +import java.util.stream.Stream; import com.fasterxml.jackson.core.type.TypeReference; import io.modelcontextprotocol.client.McpSyncClient; @@ -26,6 +29,8 @@ import io.modelcontextprotocol.server.McpAsyncServerExchange; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpServerFeatures.AsyncCompletionSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.AsyncPromptSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.AsyncResourceSpecification; import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification; import io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification; import io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification; @@ -39,9 +44,17 @@ import io.modelcontextprotocol.spec.McpServerTransportProvider; import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import org.springaicommunity.mcp.annotation.McpArg; +import org.springaicommunity.mcp.annotation.McpComplete; +import org.springaicommunity.mcp.annotation.McpPrompt; +import org.springaicommunity.mcp.annotation.McpResource; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springaicommunity.mcp.annotation.McpToolParam; import reactor.core.publisher.Mono; import org.springframework.ai.mcp.SyncMcpToolCallback; +import org.springframework.ai.mcp.server.common.autoconfigure.annotations.McpServerAnnotationScannerAutoConfiguration; +import org.springframework.ai.mcp.server.common.autoconfigure.annotations.McpServerSpecificationFactoryAutoConfiguration; import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerChangeNotificationProperties; import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerProperties; import org.springframework.ai.tool.ToolCallback; @@ -50,6 +63,8 @@ import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Component; +import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; @@ -345,6 +360,72 @@ void toolCallbackProviderConfiguration() { .run(context -> assertThat(context).hasSingleBean(ToolCallbackProvider.class)); } + @SuppressWarnings("unchecked") + @Test + void syncServerSpecificationConfiguration() { + this.contextRunner + .withUserConfiguration(McpServerAnnotationScannerAutoConfiguration.class, + McpServerSpecificationFactoryAutoConfiguration.class) + .withBean(SyncTestMcpSpecsComponent.class) + .run(context -> { + McpSyncServer syncServer = context.getBean(McpSyncServer.class); + McpAsyncServer asyncServer = (McpAsyncServer) ReflectionTestUtils.getField(syncServer, "asyncServer"); + + CopyOnWriteArrayList tools = (CopyOnWriteArrayList) ReflectionTestUtils + .getField(asyncServer, "tools"); + assertThat(tools).hasSize(1); + assertThat(tools.get(0).tool().name()).isEqualTo("add"); + + ConcurrentHashMap resources = (ConcurrentHashMap) ReflectionTestUtils + .getField(asyncServer, "resources"); + assertThat(resources).hasSize(1); + assertThat(resources.get("config://{key}")).isNotNull(); + + ConcurrentHashMap prompts = (ConcurrentHashMap) ReflectionTestUtils + .getField(asyncServer, "prompts"); + assertThat(prompts).hasSize(1); + assertThat(prompts.get("greeting")).isNotNull(); + + ConcurrentHashMap completions = (ConcurrentHashMap) ReflectionTestUtils + .getField(asyncServer, "completions"); + assertThat(completions).hasSize(1); + assertThat(completions.keySet().iterator().next()).isInstanceOf(McpSchema.CompleteReference.class); + }); + } + + @SuppressWarnings("unchecked") + @Test + void asyncServerSpecificationConfiguration() { + this.contextRunner + .withUserConfiguration(McpServerAnnotationScannerAutoConfiguration.class, + McpServerSpecificationFactoryAutoConfiguration.class) + .withBean(AsyncTestMcpSpecsComponent.class) + .withPropertyValues("spring.ai.mcp.server.type=async") + .run(context -> { + McpAsyncServer asyncServer = context.getBean(McpAsyncServer.class); + + CopyOnWriteArrayList tools = (CopyOnWriteArrayList) ReflectionTestUtils + .getField(asyncServer, "tools"); + assertThat(tools).hasSize(1); + assertThat(tools.get(0).tool().name()).isEqualTo("add"); + + ConcurrentHashMap resources = (ConcurrentHashMap) ReflectionTestUtils + .getField(asyncServer, "resources"); + assertThat(resources).hasSize(1); + assertThat(resources.get("config://{key}")).isNotNull(); + + ConcurrentHashMap prompts = (ConcurrentHashMap) ReflectionTestUtils + .getField(asyncServer, "prompts"); + assertThat(prompts).hasSize(1); + assertThat(prompts.get("greeting")).isNotNull(); + + ConcurrentHashMap completions = (ConcurrentHashMap) ReflectionTestUtils + .getField(asyncServer, "completions"); + assertThat(completions).hasSize(1); + assertThat(completions.keySet().iterator().next()).isInstanceOf(McpSchema.CompleteReference.class); + }); + } + @Configuration static class TestResourceConfiguration { @@ -516,4 +597,76 @@ McpServerTransport customTransport() { } + @Component + static class SyncTestMcpSpecsComponent { + + @McpTool(name = "add", description = "Add two numbers together", title = "Add Two Numbers Together", + annotations = @McpTool.McpAnnotations(title = "Rectangle Area Calculator", readOnlyHint = true, + destructiveHint = false, idempotentHint = true)) + public int add(@McpToolParam(description = "First number", required = true) int a, + @McpToolParam(description = "Second number", required = true) int b) { + return a + b; + } + + @McpResource(uri = "config://{key}", name = "Configuration", description = "Provides configuration data") + public String getConfig(String key) { + return "config value"; + } + + @McpPrompt(name = "greeting", description = "Generate a greeting message") + public McpSchema.GetPromptResult greeting( + @McpArg(name = "name", description = "User's name", required = true) String name) { + + String message = "Hello, " + name + "! How can I help you today?"; + + return new McpSchema.GetPromptResult("Greeting", + List.of(new McpSchema.PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent(message)))); + } + + @McpComplete(prompt = "city-search") + public List completeCityName(String prefix) { + return Stream.of("New York", "Los Angeles", "Chicago", "Houston", "Phoenix") + .filter(city -> city.toLowerCase().startsWith(prefix.toLowerCase())) + .limit(10) + .toList(); + } + + } + + @Component + static class AsyncTestMcpSpecsComponent { + + @McpTool(name = "add", description = "Add two numbers together", title = "Add Two Numbers Together", + annotations = @McpTool.McpAnnotations(title = "Rectangle Area Calculator", readOnlyHint = true, + destructiveHint = false, idempotentHint = true)) + public Mono add(@McpToolParam(description = "First number", required = true) int a, + @McpToolParam(description = "Second number", required = true) int b) { + return Mono.just(a + b); + } + + @McpResource(uri = "config://{key}", name = "Configuration", description = "Provides configuration data") + public Mono getConfig(String key) { + return Mono.just("config value"); + } + + @McpPrompt(name = "greeting", description = "Generate a greeting message") + public Mono greeting( + @McpArg(name = "name", description = "User's name", required = true) String name) { + + String message = "Hello, " + name + "! How can I help you today?"; + + return Mono.just(new McpSchema.GetPromptResult("Greeting", List + .of(new McpSchema.PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent(message))))); + } + + @McpComplete(prompt = "city-search") + public Mono> completeCityName(String prefix) { + return Mono.just(Stream.of("New York", "Los Angeles", "Chicago", "Houston", "Phoenix") + .filter(city -> city.toLowerCase().startsWith(prefix.toLowerCase())) + .limit(10) + .toList()); + } + + } + } diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/test/java/org/springframework/ai/mcp/server/common/autoconfigure/McpStatelessServerAutoConfigurationIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/test/java/org/springframework/ai/mcp/server/common/autoconfigure/McpStatelessServerAutoConfigurationIT.java index 3133d31c1cf..bc781602b96 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/test/java/org/springframework/ai/mcp/server/common/autoconfigure/McpStatelessServerAutoConfigurationIT.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/test/java/org/springframework/ai/mcp/server/common/autoconfigure/McpStatelessServerAutoConfigurationIT.java @@ -17,14 +17,19 @@ package org.springframework.ai.mcp.server.common.autoconfigure; import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.BiConsumer; import java.util.function.BiFunction; +import java.util.stream.Stream; import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.server.McpStatelessAsyncServer; import io.modelcontextprotocol.server.McpStatelessServerFeatures; import io.modelcontextprotocol.server.McpStatelessServerFeatures.AsyncCompletionSpecification; +import io.modelcontextprotocol.server.McpStatelessServerFeatures.AsyncPromptSpecification; +import io.modelcontextprotocol.server.McpStatelessServerFeatures.AsyncResourceSpecification; import io.modelcontextprotocol.server.McpStatelessServerFeatures.AsyncToolSpecification; import io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification; import io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncPromptSpecification; @@ -36,9 +41,17 @@ import io.modelcontextprotocol.spec.McpStatelessServerTransport; import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import org.springaicommunity.mcp.annotation.McpArg; +import org.springaicommunity.mcp.annotation.McpComplete; +import org.springaicommunity.mcp.annotation.McpPrompt; +import org.springaicommunity.mcp.annotation.McpResource; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springaicommunity.mcp.annotation.McpToolParam; import reactor.core.publisher.Mono; import org.springframework.ai.mcp.SyncMcpToolCallback; +import org.springframework.ai.mcp.server.common.autoconfigure.annotations.McpServerAnnotationScannerAutoConfiguration; +import org.springframework.ai.mcp.server.common.autoconfigure.annotations.StatelessServerSpecificationFactoryAutoConfiguration; import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerProperties; import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.ToolCallbackProvider; @@ -47,6 +60,8 @@ import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Component; +import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; @@ -295,6 +310,73 @@ void toolCallbackProviderConfiguration() { .run(context -> assertThat(context).hasSingleBean(ToolCallbackProvider.class)); } + @SuppressWarnings("unchecked") + @Test + void syncStatelessServerSpecificationConfiguration() { + this.contextRunner + .withUserConfiguration(McpServerAnnotationScannerAutoConfiguration.class, + StatelessServerSpecificationFactoryAutoConfiguration.class) + .withBean(SyncTestMcpSpecsComponent.class) + .run(context -> { + McpStatelessSyncServer syncServer = context.getBean(McpStatelessSyncServer.class); + McpStatelessAsyncServer asyncServer = (McpStatelessAsyncServer) ReflectionTestUtils.getField(syncServer, + "asyncServer"); + + CopyOnWriteArrayList tools = (CopyOnWriteArrayList) ReflectionTestUtils + .getField(asyncServer, "tools"); + assertThat(tools).hasSize(1); + assertThat(tools.get(0).tool().name()).isEqualTo("add"); + + ConcurrentHashMap resources = (ConcurrentHashMap) ReflectionTestUtils + .getField(asyncServer, "resources"); + assertThat(resources).hasSize(1); + assertThat(resources.get("config://{key}")).isNotNull(); + + ConcurrentHashMap prompts = (ConcurrentHashMap) ReflectionTestUtils + .getField(asyncServer, "prompts"); + assertThat(prompts).hasSize(1); + assertThat(prompts.get("greeting")).isNotNull(); + + ConcurrentHashMap completions = (ConcurrentHashMap) ReflectionTestUtils + .getField(asyncServer, "completions"); + assertThat(completions).hasSize(1); + assertThat(completions.keySet().iterator().next()).isInstanceOf(McpSchema.CompleteReference.class); + }); + } + + @SuppressWarnings("unchecked") + @Test + void asyncStatelessServerSpecificationConfiguration() { + this.contextRunner + .withUserConfiguration(McpServerAnnotationScannerAutoConfiguration.class, + StatelessServerSpecificationFactoryAutoConfiguration.class) + .withBean(AsyncTestMcpSpecsComponent.class) + .withPropertyValues("spring.ai.mcp.server.type=async") + .run(context -> { + McpStatelessAsyncServer asyncServer = context.getBean(McpStatelessAsyncServer.class); + + CopyOnWriteArrayList tools = (CopyOnWriteArrayList) ReflectionTestUtils + .getField(asyncServer, "tools"); + assertThat(tools).hasSize(1); + assertThat(tools.get(0).tool().name()).isEqualTo("add"); + + ConcurrentHashMap resources = (ConcurrentHashMap) ReflectionTestUtils + .getField(asyncServer, "resources"); + assertThat(resources).hasSize(1); + assertThat(resources.get("config://{key}")).isNotNull(); + + ConcurrentHashMap prompts = (ConcurrentHashMap) ReflectionTestUtils + .getField(asyncServer, "prompts"); + assertThat(prompts).hasSize(1); + assertThat(prompts.get("greeting")).isNotNull(); + + ConcurrentHashMap completions = (ConcurrentHashMap) ReflectionTestUtils + .getField(asyncServer, "completions"); + assertThat(completions).hasSize(1); + assertThat(completions.keySet().iterator().next()).isInstanceOf(McpSchema.CompleteReference.class); + }); + } + @Configuration static class TestResourceConfiguration { @@ -437,4 +519,76 @@ public McpStatelessServerTransport statelessTransport() { } + @Component + static class SyncTestMcpSpecsComponent { + + @McpTool(name = "add", description = "Add two numbers together", title = "Add Two Numbers Together", + annotations = @McpTool.McpAnnotations(title = "Rectangle Area Calculator", readOnlyHint = true, + destructiveHint = false, idempotentHint = true)) + public int add(@McpToolParam(description = "First number", required = true) int a, + @McpToolParam(description = "Second number", required = true) int b) { + return a + b; + } + + @McpResource(uri = "config://{key}", name = "Configuration", description = "Provides configuration data") + public String getConfig(String key) { + return "config value"; + } + + @McpPrompt(name = "greeting", description = "Generate a greeting message") + public McpSchema.GetPromptResult greeting( + @McpArg(name = "name", description = "User's name", required = true) String name) { + + String message = "Hello, " + name + "! How can I help you today?"; + + return new McpSchema.GetPromptResult("Greeting", + List.of(new McpSchema.PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent(message)))); + } + + @McpComplete(prompt = "city-search") + public List completeCityName(String prefix) { + return Stream.of("New York", "Los Angeles", "Chicago", "Houston", "Phoenix") + .filter(city -> city.toLowerCase().startsWith(prefix.toLowerCase())) + .limit(10) + .toList(); + } + + } + + @Component + static class AsyncTestMcpSpecsComponent { + + @McpTool(name = "add", description = "Add two numbers together", title = "Add Two Numbers Together", + annotations = @McpTool.McpAnnotations(title = "Rectangle Area Calculator", readOnlyHint = true, + destructiveHint = false, idempotentHint = true)) + public Mono add(@McpToolParam(description = "First number", required = true) int a, + @McpToolParam(description = "Second number", required = true) int b) { + return Mono.just(a + b); + } + + @McpResource(uri = "config://{key}", name = "Configuration", description = "Provides configuration data") + public Mono getConfig(String key) { + return Mono.just("config value"); + } + + @McpPrompt(name = "greeting", description = "Generate a greeting message") + public Mono greeting( + @McpArg(name = "name", description = "User's name", required = true) String name) { + + String message = "Hello, " + name + "! How can I help you today?"; + + return Mono.just(new McpSchema.GetPromptResult("Greeting", List + .of(new McpSchema.PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent(message))))); + } + + @McpComplete(prompt = "city-search") + public Mono> completeCityName(String prefix) { + return Mono.just(Stream.of("New York", "Los Angeles", "Chicago", "Houston", "Phoenix") + .filter(city -> city.toLowerCase().startsWith(prefix.toLowerCase())) + .limit(10) + .toList()); + } + + } + }