feat(mcp): Add granular control over MCP server capabilities

- Add capability configuration to enable/disable tools, resources, prompts, and completions individually
- Refactor server configuration to conditionally register capabilities based on configuration
- Add support for server instructions configuration
- Update documentation with new capability options and completion management
- Add tests for new capabilities and configurations

Resolves #3207

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
Christian Tzolov
2025-05-16 13:20:39 +02:00
committed by Mark Pollack
parent 9392485bd5
commit 327cf40e97
4 changed files with 367 additions and 51 deletions

View File

@@ -149,11 +149,11 @@ public class McpServerAutoConfiguration {
// De-duplicate tools by their name, keeping the first occurrence of each tool
// name
return tools.stream()
.collect(Collectors.toMap(tool -> tool.getToolDefinition().name(), // Key:
// tool
// name
tool -> tool, // Value: the tool itself
return tools.stream() // Key: tool name
.collect(Collectors.toMap(tool -> tool.getToolDefinition().name(), tool -> tool, // Value:
// the
// tool
// itself
(existing, replacement) -> existing)) // On duplicate key, keep the
// existing tool
.values()
@@ -185,47 +185,66 @@ public class McpServerAutoConfiguration {
// Create the server with both tool and resource capabilities
SyncSpecification serverBuilder = McpServer.sync(transportProvider).serverInfo(serverInfo);
List<SyncToolSpecification> toolSpecifications = new ArrayList<>(tools.stream().flatMap(List::stream).toList());
List<ToolCallback> providerToolCallbacks = toolCallbackProvider.stream()
.map(pr -> List.of(pr.getToolCallbacks()))
.flatMap(List::stream)
.filter(fc -> fc instanceof ToolCallback)
.map(fc -> (ToolCallback) fc)
.toList();
toolSpecifications.addAll(this.toSyncToolSpecifications(providerToolCallbacks, serverProperties));
if (!CollectionUtils.isEmpty(toolSpecifications)) {
serverBuilder.tools(toolSpecifications);
// Tools
if (serverProperties.getCapabilities().isTool()) {
logger.info("Enable tools capabilities, notification: " + serverProperties.isToolChangeNotification());
capabilitiesBuilder.tools(serverProperties.isToolChangeNotification());
logger.info("Registered tools: " + toolSpecifications.size() + ", notification: "
+ serverProperties.isToolChangeNotification());
List<SyncToolSpecification> toolSpecifications = new ArrayList<>(
tools.stream().flatMap(List::stream).toList());
List<ToolCallback> providerToolCallbacks = toolCallbackProvider.stream()
.map(pr -> List.of(pr.getToolCallbacks()))
.flatMap(List::stream)
.filter(fc -> fc instanceof ToolCallback)
.map(fc -> (ToolCallback) fc)
.toList();
toolSpecifications.addAll(this.toSyncToolSpecifications(providerToolCallbacks, serverProperties));
if (!CollectionUtils.isEmpty(toolSpecifications)) {
serverBuilder.tools(toolSpecifications);
logger.info("Registered tools: " + toolSpecifications.size());
}
}
List<SyncResourceSpecification> resourceSpecifications = resources.stream().flatMap(List::stream).toList();
if (!CollectionUtils.isEmpty(resourceSpecifications)) {
serverBuilder.resources(resourceSpecifications);
// Resources
if (serverProperties.getCapabilities().isResource()) {
logger.info(
"Enable resources capabilities, notification: " + serverProperties.isResourceChangeNotification());
capabilitiesBuilder.resources(false, serverProperties.isResourceChangeNotification());
logger.info("Registered resources: " + resourceSpecifications.size() + ", notification: "
+ serverProperties.isResourceChangeNotification());
List<SyncResourceSpecification> resourceSpecifications = resources.stream().flatMap(List::stream).toList();
if (!CollectionUtils.isEmpty(resourceSpecifications)) {
serverBuilder.resources(resourceSpecifications);
logger.info("Registered resources: " + resourceSpecifications.size());
}
}
List<SyncPromptSpecification> promptSpecifications = prompts.stream().flatMap(List::stream).toList();
if (!CollectionUtils.isEmpty(promptSpecifications)) {
serverBuilder.prompts(promptSpecifications);
// Prompts
if (serverProperties.getCapabilities().isPrompt()) {
logger.info("Enable prompts capabilities, notification: " + serverProperties.isPromptChangeNotification());
capabilitiesBuilder.prompts(serverProperties.isPromptChangeNotification());
logger.info("Registered prompts: " + promptSpecifications.size() + ", notification: "
+ serverProperties.isPromptChangeNotification());
List<SyncPromptSpecification> promptSpecifications = prompts.stream().flatMap(List::stream).toList();
if (!CollectionUtils.isEmpty(promptSpecifications)) {
serverBuilder.prompts(promptSpecifications);
logger.info("Registered prompts: " + promptSpecifications.size());
}
}
List<SyncCompletionSpecification> completionSpecifications = completions.stream()
.flatMap(List::stream)
.toList();
if (!CollectionUtils.isEmpty(completionSpecifications)) {
serverBuilder.completions(completionSpecifications);
// Completions
if (serverProperties.getCapabilities().isCompletion()) {
logger.info("Enable completions capabilities");
capabilitiesBuilder.completions();
logger.info("Registered completions: " + completionSpecifications.size());
List<SyncCompletionSpecification> completionSpecifications = completions.stream()
.flatMap(List::stream)
.toList();
if (!CollectionUtils.isEmpty(completionSpecifications)) {
serverBuilder.completions(completionSpecifications);
logger.info("Registered completions: " + completionSpecifications.size());
}
}
rootsChangeConsumers.ifAvailable(consumer -> {
@@ -257,11 +276,11 @@ public class McpServerAutoConfiguration {
McpServerProperties serverProperties) {
// De-duplicate tools by their name, keeping the first occurrence of each tool
// name
return tools.stream()
.collect(Collectors.toMap(tool -> tool.getToolDefinition().name(), // Key:
// tool
// name
tool -> tool, // Value: the tool itself
return tools.stream() // Key: tool name
.collect(Collectors.toMap(tool -> tool.getToolDefinition().name(), tool -> tool, // Value:
// the
// tool
// itself
(existing, replacement) -> existing)) // On duplicate key, keep the
// existing tool
.values()
@@ -303,35 +322,51 @@ public class McpServerAutoConfiguration {
toolSpecifications.addAll(this.toAsyncToolSpecification(providerToolCallbacks, serverProperties));
// Tools
if (serverProperties.getCapabilities().isTool()) {
logger.info("Enable tools capabilities, notification: " + serverProperties.isToolChangeNotification());
capabilitiesBuilder.tools(serverProperties.isToolChangeNotification());
}
if (!CollectionUtils.isEmpty(toolSpecifications)) {
serverBuilder.tools(toolSpecifications);
capabilitiesBuilder.tools(serverProperties.isToolChangeNotification());
logger.info("Registered tools: " + toolSpecifications.size() + ", notification: "
+ serverProperties.isToolChangeNotification());
logger.info("Registered tools: " + toolSpecifications.size());
}
// Resources
if (serverProperties.getCapabilities().isResource()) {
logger.info(
"Enable resources capabilities, notification: " + serverProperties.isResourceChangeNotification());
capabilitiesBuilder.resources(false, serverProperties.isResourceChangeNotification());
}
List<AsyncResourceSpecification> resourceSpecifications = resources.stream().flatMap(List::stream).toList();
if (!CollectionUtils.isEmpty(resourceSpecifications)) {
serverBuilder.resources(resourceSpecifications);
capabilitiesBuilder.resources(false, serverProperties.isResourceChangeNotification());
logger.info("Registered resources: " + resourceSpecifications.size() + ", notification: "
+ serverProperties.isResourceChangeNotification());
logger.info("Registered resources: " + resourceSpecifications.size());
}
// Prompts
if (serverProperties.getCapabilities().isPrompt()) {
logger.info("Enable prompts capabilities, notification: " + serverProperties.isPromptChangeNotification());
capabilitiesBuilder.prompts(serverProperties.isPromptChangeNotification());
}
List<AsyncPromptSpecification> promptSpecifications = prompts.stream().flatMap(List::stream).toList();
if (!CollectionUtils.isEmpty(promptSpecifications)) {
serverBuilder.prompts(promptSpecifications);
capabilitiesBuilder.prompts(serverProperties.isPromptChangeNotification());
logger.info("Registered prompts: " + promptSpecifications.size() + ", notification: "
+ serverProperties.isPromptChangeNotification());
logger.info("Registered prompts: " + promptSpecifications.size());
}
// Completions
if (serverProperties.getCapabilities().isCompletion()) {
logger.info("Enable completions capabilities");
capabilitiesBuilder.completions();
}
List<AsyncCompletionSpecification> completionSpecifications = completions.stream()
.flatMap(List::stream)
.toList();
if (!CollectionUtils.isEmpty(completionSpecifications)) {
serverBuilder.completions(completionSpecifications);
capabilitiesBuilder.completions();
logger.info("Registered completions: " + completionSpecifications.size());
}

View File

@@ -132,6 +132,56 @@ public class McpServerProperties {
*/
private ServerType type = ServerType.SYNC;
private Capabilities capabilities = new Capabilities();
public static class Capabilities {
private boolean resource = true;
private boolean tool = true;
private boolean prompt = true;
private boolean completion = true;
public boolean isResource() {
return resource;
}
public void setResource(boolean resource) {
this.resource = resource;
}
public boolean isTool() {
return tool;
}
public void setTool(boolean tool) {
this.tool = tool;
}
public boolean isPrompt() {
return prompt;
}
public void setPrompt(boolean prompt) {
this.prompt = prompt;
}
public boolean isCompletion() {
return completion;
}
public void setCompletion(boolean completion) {
this.completion = completion;
}
}
public Capabilities getCapabilities() {
return capabilities;
}
/**
* Server types supported by the MCP server.
*/

View File

@@ -18,11 +18,16 @@ package org.springframework.ai.mcp.server.autoconfigure;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import com.fasterxml.jackson.core.type.TypeReference;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.server.McpAsyncServer;
import io.modelcontextprotocol.server.McpAsyncServerExchange;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.server.McpServerFeatures.AsyncCompletionSpecification;
import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification;
import io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification;
import io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification;
import io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification;
import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification;
@@ -38,6 +43,7 @@ import reactor.core.publisher.Mono;
import org.springframework.ai.mcp.SyncMcpToolCallback;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
@@ -66,6 +72,12 @@ public class McpServerAutoConfigurationIT {
assertThat(properties.isToolChangeNotification()).isTrue();
assertThat(properties.isResourceChangeNotification()).isTrue();
assertThat(properties.isPromptChangeNotification()).isTrue();
// Check capabilities
assertThat(properties.getCapabilities().isTool()).isTrue();
assertThat(properties.getCapabilities().isResource()).isTrue();
assertThat(properties.getCapabilities().isPrompt()).isTrue();
assertThat(properties.getCapabilities().isCompletion()).isTrue();
});
}
@@ -86,6 +98,18 @@ public class McpServerAutoConfigurationIT {
});
}
@Test
void syncServerInstructionsConfiguration() {
this.contextRunner.withPropertyValues("spring.ai.mcp.server.instructions=Sync Server Instructions")
.run(context -> {
McpServerProperties properties = context.getBean(McpServerProperties.class);
assertThat(properties.getInstructions()).isEqualTo("Sync Server Instructions");
McpSyncServer server = context.getBean(McpSyncServer.class);
assertThat(server).isNotNull();
});
}
@Test
void transportConfiguration() {
this.contextRunner.withUserConfiguration(CustomTransportConfiguration.class).run(context -> {
@@ -207,6 +231,16 @@ public class McpServerAutoConfigurationIT {
});
}
@Test
void asyncRootsChangeHandlerConfiguration() {
this.contextRunner.withPropertyValues("spring.ai.mcp.server.type=ASYNC")
.withUserConfiguration(TestAsyncRootsHandlerConfiguration.class)
.run(context -> {
McpAsyncServer server = context.getBean(McpAsyncServer.class);
assertThat(server).isNotNull();
});
}
@Configuration
static class TestResourceConfiguration {
@@ -243,6 +277,63 @@ public class McpServerAutoConfigurationIT {
}
@Test
void capabilitiesConfiguration() {
this.contextRunner.withPropertyValues("spring.ai.mcp.server.capabilities.tool=false",
"spring.ai.mcp.server.capabilities.resource=false", "spring.ai.mcp.server.capabilities.prompt=false",
"spring.ai.mcp.server.capabilities.completion=false")
.run(context -> {
McpServerProperties properties = context.getBean(McpServerProperties.class);
assertThat(properties.getCapabilities().isTool()).isFalse();
assertThat(properties.getCapabilities().isResource()).isFalse();
assertThat(properties.getCapabilities().isPrompt()).isFalse();
assertThat(properties.getCapabilities().isCompletion()).isFalse();
});
}
@Test
void toolResponseMimeTypeConfiguration() {
this.contextRunner.withPropertyValues("spring.ai.mcp.server.tool-response-mime-type.test-tool=application/json")
.withUserConfiguration(TestToolConfiguration.class)
.run(context -> {
McpServerProperties properties = context.getBean(McpServerProperties.class);
assertThat(properties.getToolResponseMimeType()).containsEntry("test-tool", "application/json");
// Verify the MIME type is applied to the tool specifications
List<SyncToolSpecification> tools = context.getBean("syncTools", List.class);
assertThat(tools).hasSize(1);
// The server should be properly configured with the tool
McpSyncServer server = context.getBean(McpSyncServer.class);
assertThat(server).isNotNull();
});
}
@Test
void completionSpecificationConfiguration() {
this.contextRunner.withUserConfiguration(TestCompletionConfiguration.class).run(context -> {
List<SyncCompletionSpecification> completions = context.getBean("testCompletions", List.class);
assertThat(completions).hasSize(1);
});
}
@Test
void asyncCompletionSpecificationConfiguration() {
this.contextRunner.withPropertyValues("spring.ai.mcp.server.type=ASYNC")
.withUserConfiguration(TestAsyncCompletionConfiguration.class)
.run(context -> {
List<AsyncCompletionSpecification> completions = context.getBean("testAsyncCompletions", List.class);
assertThat(completions).hasSize(1);
});
}
@Test
void toolCallbackProviderConfiguration() {
this.contextRunner.withUserConfiguration(TestToolCallbackProviderConfiguration.class).run(context -> {
assertThat(context).hasSingleBean(ToolCallbackProvider.class);
});
}
@Configuration
static class TestToolConfiguration {
@@ -262,6 +353,61 @@ public class McpServerAutoConfigurationIT {
}
@Configuration
static class TestToolCallbackProviderConfiguration {
@Bean
ToolCallbackProvider testToolCallbackProvider() {
return () -> {
McpSyncClient mockClient = Mockito.mock(McpSyncClient.class);
McpSchema.Tool mockTool = Mockito.mock(McpSchema.Tool.class);
Mockito.when(mockTool.name()).thenReturn("provider-tool");
Mockito.when(mockTool.description()).thenReturn("Provider Tool");
when(mockClient.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient", "1.0.0"));
return new ToolCallback[] { new SyncMcpToolCallback(mockClient, mockTool) };
};
}
}
@Configuration
static class TestCompletionConfiguration {
@Bean
List<SyncCompletionSpecification> testCompletions() {
BiFunction<McpSyncServerExchange, McpSchema.CompleteRequest, McpSchema.CompleteResult> completionHandler = (
exchange, request) -> {
// Test implementation
return new McpSchema.CompleteResult(
new McpSchema.CompleteResult.CompleteCompletion(List.of(), 0, false));
};
return List.of(new McpServerFeatures.SyncCompletionSpecification(
new McpSchema.PromptReference("ref/prompt", "code_review"), completionHandler));
}
}
@Configuration
static class TestAsyncCompletionConfiguration {
@Bean
List<AsyncCompletionSpecification> testAsyncCompletions() {
BiFunction<McpAsyncServerExchange, McpSchema.CompleteRequest, Mono<McpSchema.CompleteResult>> completionHandler = (
exchange, request) -> {
// Test implementation
return Mono.just(new McpSchema.CompleteResult(
new McpSchema.CompleteResult.CompleteCompletion(List.of(), 0, false)));
};
return List.of(new McpServerFeatures.AsyncCompletionSpecification(
new McpSchema.PromptReference("ref/prompt", "code_review"), completionHandler));
}
}
@Configuration
static class TestRootsHandlerConfiguration {
@@ -274,6 +420,18 @@ public class McpServerAutoConfigurationIT {
}
@Configuration
static class TestAsyncRootsHandlerConfiguration {
@Bean
BiConsumer<McpAsyncServerExchange, List<McpSchema.Root>> rootsChangeHandler() {
return (exchange, roots) -> {
// Test implementation
};
}
}
static class CustomServerTransport implements McpServerTransport {
@Override

View File

@@ -91,7 +91,12 @@ All properties are prefixed with `spring.ai.mcp.server`:
|`stdio` |Enable/disable stdio transport |`false`
|`name` |Server name for identification |`mcp-server`
|`version` |Server version |`1.0.0`
|`instructions` |Optional instructions to provide guidance to the client on how to interact with this server |`null`
|`type` |Server type (SYNC/ASYNC) |`SYNC`
|`capabilities.resource` |Enable/disable resource capabilities |`true`
|`capabilities.tool` |Enable/disable tool capabilities |`true`
|`capabilities.prompt` |Enable/disable prompt capabilities |`true`
|`capabilities.completion` |Enable/disable completion capabilities |`true`
|`resource-change-notification` |Enable resource change notifications |`true`
|`prompt-change-notification` |Enable prompt change notifications |`true`
|`tool-change-notification` |Enable tool change notifications |`true`
@@ -112,6 +117,17 @@ When activated, it automatically handles the configuration of synchronous tool s
To enable this server type, configure your application with `spring.ai.mcp.server.type=ASYNC`.
This server type automatically sets up asynchronous tool specifications with built-in Project Reactor support.
== Server Capabilities
The MCP Server supports four main capability types that can be individually enabled or disabled:
* **Tools** - Enable/disable tool capabilities with `spring.ai.mcp.server.capabilities.tool=true|false`
* **Resources** - Enable/disable resource capabilities with `spring.ai.mcp.server.capabilities.resource=true|false`
* **Prompts** - Enable/disable prompt capabilities with `spring.ai.mcp.server.capabilities.prompt=true|false`
* **Completions** - Enable/disable completion capabilities with `spring.ai.mcp.server.capabilities.completion=true|false`
All capabilities are enabled by default. Disabling a capability will prevent the server from registering and exposing the corresponding features to clients.
== Transport Options
The MCP Server supports three transport mechanisms, each with its dedicated starter:
@@ -141,6 +157,17 @@ public ToolCallbackProvider myTools(...) {
}
----
or directly as individual tool callbacks:
[source,java]
----
@Bean
public ToolCallback myTool(...) {
// Create and return a single tool callback
return new ToolCallback(...);
}
----
or using the low-level API:
[source,java]
@@ -152,6 +179,13 @@ public List<McpServerFeatures.SyncToolSpecification> myTools(...) {
}
----
The auto-configuration will automatically detect and register all tool callbacks from:
* Individual `ToolCallback` beans
* Lists of `ToolCallback` beans
* `ToolCallbackProvider` beans
Tools are de-duplicated by name, with the first occurrence of each tool name being used.
=== link:https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/[Resource Management]
Provides a standardized way for servers to expose resources to clients.
@@ -210,6 +244,33 @@ public List<McpServerFeatures.SyncPromptSpecification> myPrompts() {
}
----
=== link:https://spec.modelcontextprotocol.io/specification/2024-11-05/server/completions/[Completion Management]
Provides a standardized way for servers to expose completion capabilities to clients.
* Support for both sync and async completion specifications
* Automatic registration through Spring beans:
[source,java]
----
@Bean
public List<McpServerFeatures.SyncCompletionSpecification> myCompletions() {
var completion = new McpServerFeatures.SyncCompletionSpecification(
"code-completion",
"Provides code completion suggestions",
(exchange, request) -> {
// Implementation that returns completion suggestions
return new McpSchema.CompletionResult(List.of(
new McpSchema.Completion("suggestion1", "First suggestion"),
new McpSchema.Completion("suggestion2", "Second suggestion")
));
}
);
return List.of(completion);
}
----
=== link:https://spec.modelcontextprotocol.io/specification/2024-11-05/client/roots/#root-list-changes[Root Change Consumers]
When roots change, clients that support `listChanged` send a Root Change notification.
@@ -254,7 +315,13 @@ spring:
name: webmvc-mcp-server
version: 1.0.0
type: SYNC
instructions: "This server provides weather information tools and resources"
sse-message-endpoint: /mcp/messages
capabilities:
tool: true
resource: true
prompt: true
completion: true
----
=== WebFlux Server Configuration
@@ -268,7 +335,13 @@ spring:
name: webflux-mcp-server
version: 1.0.0
type: ASYNC # Recommended for reactive applications
instructions: "This reactive server provides weather information tools and resources"
sse-message-endpoint: /mcp/messages
capabilities:
tool: true
resource: true
prompt: true
completion: true
----
=== Creating a Spring Boot Application with MCP Server