Refactor McpClient for async and sync interactions

Introduce new builder patterns and enhance type safety

- Add new builder patterns for McpClient and McpServer with sync/async variants
- Introduce dedicated McpClientFeatures and McpServerFeatures classes
- Enhance type safety and reactive support with proper Mono integration
- Add migration guide for 0.6.0 release
- Mark old builder methods as deprecated
- Update documentation to reflect new APIs

BREAKING CHANGE: The old builder patterns using McpClient.using() and McpServer.using()
are now deprecated in favor of new sync() and async() builders that provide
better type safety and clearer sync/async separation. Follow the migration guide.

Resolves #48

Signed-off-by: Dariusz Jędrzejczyk <dariusz.jedrzejczyk@broadcom.com>
Co-authored-by: Christian Tzolov <ctzolov@vmware.com>
This commit is contained in:
Dariusz Jędrzejczyk 2025-01-20 18:33:06 +01:00 • committed by Christian Tzolov
commit 6839d3b740
25 changed files with 3494 additions and 753 deletions

View file

@ -0,0 +1,258 @@
# Spring AI MCP 0.5.0 Migration Guide
This guide outlines the steps required to migrate your code to Spring AI MCP 0.6.0.
## Key Changes
- New builder patterns for `McpClient` and `McpServer`
- Introduction of dedicated feature classes for sync/async operations
- Enhanced type safety and reactive support
- Deprecated methods and classes marked for removal
## Client Migration
### Creating Clients
Before:
```java
// Sync client
McpClient.using(transport)
.requestTimeout(Duration.ofSeconds(5))
.sync();
// Async client
McpClient.using(transport)
.requestTimeout(Duration.ofSeconds(5))
.async();
```
After:
```java
// Sync client
McpClient.sync(transport)
.requestTimeout(Duration.ofSeconds(5))
.build();
// Async client
McpClient.async(transport)
.requestTimeout(Duration.ofSeconds(5))
.build();
```
### Change Consumers
Before:
```java
// Sync client
McpClient.using(transport)
.toolsChangeConsumer(tools -> handleTools(tools))
.resourcesChangeConsumer(resources -> handleResources(resources))
.promptsChangeConsumer(prompts -> handlePrompts(prompts))
.sync();
// Async client
McpClient.using(transport)
.toolsChangeConsumer(tools -> handleTools(tools))
.resourcesChangeConsumer(resources -> handleResources(resources))
.promptsChangeConsumer(prompts -> handlePrompts(prompts))
.async();
```
After:
```java
// Sync client
McpClient.sync(transport)
.toolsChangeConsumer(tools -> handleTools(tools))
.resourcesChangeConsumer(resources -> handleResources(resources))
.promptsChangeConsumer(prompts -> handlePrompts(prompts))
.build();
// Async client
McpClient.async(transport)
.toolsChangeConsumer(tools -> Mono.fromRunnable(() -> handleTools(tools)))
.resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> handleResources(resources)))
.promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> handlePrompts(prompts)))
.build();
```
### Sampling Handlers
Before:
```java
// Sync client
McpClient.using(transport)
.sampling(request -> new CreateMessageResult("response"))
.sync();
// Async client
McpClient.using(transport)
.sampling(request -> new CreateMessageResult("response"))
.async();
```
After:
```java
// Sync client
McpClient.sync(transport)
.sampling(request -> new CreateMessageResult("response"))
.build();
// Async client
McpClient.async(transport)
.sampling(request -> Mono.just(new CreateMessageResult("response")))
.build();
```
## Server Migration
### Creating Servers
Before:
```java
// Sync server
McpServer.using(transport)
.serverInfo("test-server", "1.0.0")
.sync();
// Async server
McpServer.using(transport)
.serverInfo("test-server", "1.0.0")
.async();
```
After:
```java
// Sync server
McpServer.sync(transport)
.serverInfo("test-server", "1.0.0")
.build();
// Async server
McpServer.async(transport)
.serverInfo("test-server", "1.0.0")
.build();
```
### Tool Registration
Before:
```java
// Using ToolRegistration record
new ToolRegistration(
new Tool("calculator", "Performs calculations", schema),
args -> new CallToolResult("result")
);
```
After:
```java
// Sync server
new McpServerFeatures.SyncToolRegistration(
new Tool("calculator", "Performs calculations", schema),
args -> new CallToolResult("result")
);
// Async server
new McpServerFeatures.AsyncToolRegistration(
new Tool("calculator", "Performs calculations", schema),
args -> Mono.just(new CallToolResult("result"))
);
```
### Resource Registration
Before:
```java
// Using ResourceRegistration record
new ResourceRegistration(
new Resource("docs", "Documentation", "text/markdown"),
request -> new ReadResourceResult(content)
);
```
After:
```java
// Sync server
new McpServerFeatures.SyncResourceRegistration(
new Resource("docs", "Documentation", "text/markdown"),
request -> new ReadResourceResult(content)
);
// Async server
new McpServerFeatures.AsyncResourceRegistration(
new Resource("docs", "Documentation", "text/markdown"),
request -> Mono.just(new ReadResourceResult(content))
);
```
### Prompt Registration
Before:
```java
// Using PromptRegistration record
new PromptRegistration(
new Prompt("analyze", "Code analysis"),
request -> new GetPromptResult("result")
);
```
After:
```java
// Sync server
new McpServerFeatures.SyncPromptRegistration(
new Prompt("analyze", "Code analysis"),
request -> new GetPromptResult("result")
);
// Async server
new McpServerFeatures.AsyncPromptRegistration(
new Prompt("analyze", "Code analysis"),
request -> Mono.just(new GetPromptResult("result"))
);
```
## Spring Integration Changes
### Tool Helper Changes
Before:
```java
ToolHelper.toToolRegistration(functionCallback);
ToolHelper.toToolRegistration(functionCallbacks);
```
After:
```java
ToolHelper.toSyncToolRegistration(functionCallback);
ToolHelper.toSyncToolRegistration(functionCallbacks);
```
## Deprecated APIs
The following APIs are deprecated and will be removed in a future release:
- `McpClient.using()` - Use `McpClient.sync()` or `McpClient.async()` instead
- `McpServer.using()` - Use `McpServer.sync()` or `McpServer.async()` instead
- `McpServer.ToolRegistration` - Use `McpServerFeatures.SyncToolRegistration` or `McpServerFeatures.AsyncToolRegistration` instead
- `McpServer.ResourceRegistration` - Use `McpServerFeatures.SyncResourceRegistration` or `McpServerFeatures.AsyncResourceRegistration` instead
- `McpServer.PromptRegistration` - Use `McpServerFeatures.SyncPromptRegistration` or `McpServerFeatures.AsyncPromptRegistration` instead
- `ToolHelper.toToolRegistration()` - Use `ToolHelper.toSyncToolRegistration()` instead
## Benefits of Migration
1. **Improved Type Safety**: The new builder patterns and feature classes provide better compile-time type checking.
2. **Clear Async/Sync Separation**: Distinct builders and features for sync and async operations make the code intent clearer.
3. **Enhanced Reactive Support**: Async operations now properly integrate with Project Reactor's `Mono` type.
4. **Better Error Handling**: More consistent error handling across sync and async operations.
5. **Simplified Configuration**: Builder patterns provide a more intuitive way to configure clients and servers.
## Additional Notes
- The migration primarily focuses on builder patterns and feature organization
- Functional behavior remains the same after migration
- Async operations now properly integrate with Project Reactor
- All deprecated APIs will continue to work but will be removed in a future release

View file

@ -129,14 +129,14 @@ Sync API::
[source,java]
----
// Create a sync client with custom configuration
McpSyncClient client = McpClient.using(transport)
McpSyncClient client = McpClient.sync(transport)
.requestTimeout(Duration.ofSeconds(10))
.capabilities(ClientCapabilities.builder()
.roots(true) // Enable roots capability
.sampling() // Enable sampling capability
.build())
.sampling( request -> new CreateMessageResult(response))
.sync();
.sampling(request -> new CreateMessageResult(response))
.build();
// Initialize connection
client.initialize();
@ -175,23 +175,23 @@ Async API::
[source,java]
----
// Create an async client with custom configuration
McpAsyncClient client = McpClient.using(transport)
McpAsyncClient client = McpClient.async(transport)
.requestTimeout(Duration.ofSeconds(10))
.capabilities(ClientCapabilities.builder()
.roots(true) // Enable roots capability
.sampling() // Enable sampling capability
.build())
.sampling( request -> new CreateMessageResult(response))
.toolsChangeConsumer(tools -> {
.sampling(request -> Mono.just(new CreateMessageResult(response)))
.toolsChangeConsumer(tools -> Mono.fromRunnable(() -> {
logger.info("Tools updated: {}", tools);
})
.resourcesChangeConsumer(resources -> {
}))
.resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> {
logger.info("Resources updated: {}", resources);
})
.promptsChangeConsumer(prompts -> {
}))
.promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> {
logger.info("Prompts updated: {}", prompts);
})
.async();
}))
.build();
// Initialize connection
client.initialize()
@ -361,13 +361,13 @@ Sync API::
[source,java]
----
// Create a server with custom configuration
McpSyncServer syncServer = McpServer.using(transport)
.info("my-server", "1.0.0")
McpSyncServer syncServer = McpServer.sync(transport)
.serverInfo("my-server", "1.0.0")
.capabilities(ServerCapabilities.builder()...build())
.tools(new CalculatorTool())
.resources(resourceRegistration)
.prompts(promptRegistration)
.sync();
.tools(new McpServerFeatures.SyncToolRegistration(calculatorTool, calculatorHandler))
.resources(new McpServerFeatures.SyncResourceRegistration(resource, resourceHandler))
.prompts(new McpServerFeatures.SyncPromptRegistration(prompt, promptHandler))
.build();
// Add a tool handler at runtime
syncServer.addTool(new CalculatorTool());
@ -396,13 +396,13 @@ Async API::
[source,java]
----
// Create an async server with custom configuration
McpAsyncServer asyncServer = McpServer.using(transport)
.info("my-server", "1.0.0")
McpAsyncServer asyncServer = McpServer.async(transport)
.serverInfo("my-server", "1.0.0")
.capabilities(ServerCapabilities.builder()...build())
.tools(new CalculatorTool())
.resources(resourceRegistration)
.prompts(promptRegistration)
.async();
.tools(new McpServerFeatures.AsyncToolRegistration(calculatorTool, args -> Mono.just(calculatorHandler.apply(args))))
.resources(new McpServerFeatures.AsyncResourceRegistration(resource, req -> Mono.just(resourceHandler.apply(req))))
.prompts(new McpServerFeatures.AsyncPromptRegistration(prompt, req -> Mono.just(promptHandler.apply(req))))
.build();
// Add a tool handler at runtime
asyncServer.addTool(new CalculatorTool())
@ -593,7 +593,8 @@ Supported logging levels (in order of increasing severity): DEBUG (0), INFO (1),
[source,java]
----
var toolRegistration = new ToolRegistration(
// Sync tool registration
var syncToolRegistration = new McpServerFeatures.SyncToolRegistration(
new Tool("calculator", "Basic calculator", Map.of(
"operation", "string",
"a", "number",
@ -604,26 +605,50 @@ var toolRegistration = new ToolRegistration(
return new CallToolResult(result, false);
}
);
// Async tool registration
var asyncToolRegistration = new McpServerFeatures.AsyncToolRegistration(
new Tool("calculator", "Basic calculator", Map.of(
"operation", "string",
"a", "number",
"b", "number"
)),
arguments -> {
// Tool implementation
return Mono.just(new CallToolResult(result, false));
}
);
----
==== Resource Registration
[source,java]
----
var resourceRegistration = new ResourceRegistration(
// Sync resource registration
var syncResourceRegistration = new McpServerFeatures.SyncResourceRegistration(
new Resource("custom://resource", "name", "description", "mime-type", null),
request -> {
// Resource read implementation
return new ReadResourceResult(contents);
}
);
// Async resource registration
var asyncResourceRegistration = new McpServerFeatures.AsyncResourceRegistration(
new Resource("custom://resource", "name", "description", "mime-type", null),
request -> {
// Resource read implementation
return Mono.just(new ReadResourceResult(contents));
}
);
----
==== Prompt Registration
[source,java]
----
var promptRegistration = new PromptRegistration(
// Sync prompt registration
var syncPromptRegistration = new McpServerFeatures.SyncPromptRegistration(
new Prompt("greeting", "description", List.of(
new PromptArgument("name", "description", true)
)),
@ -632,6 +657,17 @@ var promptRegistration = new PromptRegistration(
return new GetPromptResult(description, messages);
}
);
// Async prompt registration
var asyncPromptRegistration = new McpServerFeatures.AsyncPromptRegistration(
new Prompt("greeting", "description", List.of(
new PromptArgument("name", "description", true)
)),
request -> {
// Prompt implementation
return Mono.just(new GetPromptResult(description, messages));
}
);
----
== Error Handling

View file

@ -25,6 +25,7 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import org.springframework.ai.mcp.spec.ClientMcpTransport;
@ -77,10 +78,10 @@ public abstract class BlaAbstractMcpAsyncClientTests {
this.mcpTransport = createMcpTransport();
assertThatCode(() -> {
mcpAsyncClient = McpClient.using(mcpTransport)
mcpAsyncClient = McpClient.async(mcpTransport)
.requestTimeout(TIMEOUT)
.capabilities(ClientCapabilities.builder().roots(true).build())
.async();
.build();
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
}).doesNotThrowAnyException();
}
@ -96,10 +97,10 @@ public abstract class BlaAbstractMcpAsyncClientTests {
@Test
void testConstructorWithInvalidArguments() {
assertThatThrownBy(() -> McpClient.using(null).sync()).isInstanceOf(IllegalArgumentException.class)
assertThatThrownBy(() -> McpClient.sync(null).build()).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Transport must not be null");
assertThatThrownBy(() -> McpClient.using(mcpTransport).requestTimeout(null).sync())
assertThatThrownBy(() -> McpClient.sync(mcpTransport).requestTimeout(null).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Request timeout must not be null");
}
@ -195,10 +196,10 @@ public abstract class BlaAbstractMcpAsyncClientTests {
void testInitializeWithRootsListProviders() {
var transport = createMcpTransport();
var client = McpClient.using(transport)
var client = McpClient.async(transport)
.requestTimeout(TIMEOUT)
.roots(new Root("file:///test/path", "test-root"))
.async();
.build();
assertThatCode(() -> client.initialize().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
@ -277,12 +278,12 @@ public abstract class BlaAbstractMcpAsyncClientTests {
AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false);
var transport = createMcpTransport();
var client = McpClient.using(transport)
var client = McpClient.async(transport)
.requestTimeout(TIMEOUT)
.toolsChangeConsumer(tools -> toolsNotificationReceived.set(true))
.resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true))
.promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true))
.async();
.toolsChangeConsumer(tools -> Mono.fromRunnable(() -> toolsNotificationReceived.set(true)))
.resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true)))
.promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true)))
.build();
assertThatCode(() -> {
client.initialize().block();
@ -299,9 +300,11 @@ public abstract class BlaAbstractMcpAsyncClientTests {
var capabilities = ClientCapabilities.builder().sampling().build();
var client = McpClient.using(transport).requestTimeout(TIMEOUT).capabilities(capabilities).sampling(request -> {
return CreateMessageResult.builder().message("test").model("test-model").build();
}).async();
var client = McpClient.async(transport)
.requestTimeout(TIMEOUT)
.capabilities(capabilities)
.sampling(request -> Mono.just(CreateMessageResult.builder().message("test").model("test-model").build()))
.build();
assertThatCode(() -> {
client.initialize().block(Duration.ofSeconds(10));
@ -319,14 +322,13 @@ public abstract class BlaAbstractMcpAsyncClientTests {
.sampling()
.build();
Function<CreateMessageRequest, CreateMessageResult> samplingHandler = request -> {
return CreateMessageResult.builder().message("test").model("test-model").build();
};
var client = McpClient.using(transport)
Function<CreateMessageRequest, Mono<CreateMessageResult>> samplingHandler = request -> Mono
.just(CreateMessageResult.builder().message("test").model("test-model").build());
var client = McpClient.async(transport)
.requestTimeout(TIMEOUT)
.capabilities(capabilities)
.sampling(samplingHandler)
.async();
.build();
assertThatCode(() -> {
var result = client.initialize().block(Duration.ofSeconds(10));
@ -353,10 +355,10 @@ public abstract class BlaAbstractMcpAsyncClientTests {
AtomicBoolean logReceived = new AtomicBoolean(false);
var transport = createMcpTransport();
var client = McpClient.using(transport)
var client = McpClient.async(transport)
.requestTimeout(TIMEOUT)
.loggingConsumer(notification -> logReceived.set(true))
.async();
.loggingConsumer(notification -> Mono.fromRunnable(() -> logReceived.set(true)))
.build();
assertThatCode(() -> {
client.initialize().block(Duration.ofSeconds(10));

View file

@ -72,10 +72,10 @@ public abstract class BlaAbstractMcpSyncClientTests {
this.mcpTransport = createMcpTransport();
assertThatCode(() -> {
mcpSyncClient = McpClient.using(mcpTransport)
mcpSyncClient = McpClient.sync(mcpTransport)
.requestTimeout(TIMEOUT)
.capabilities(ClientCapabilities.builder().roots(true).build())
.sync();
.build();
mcpSyncClient.initialize();
}).doesNotThrowAnyException();
}
@ -90,10 +90,10 @@ public abstract class BlaAbstractMcpSyncClientTests {
@Test
void testConstructorWithInvalidArguments() {
assertThatThrownBy(() -> McpClient.using(null).sync()).isInstanceOf(IllegalArgumentException.class)
assertThatThrownBy(() -> McpClient.sync(null).build()).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Transport must not be null");
assertThatThrownBy(() -> McpClient.using(mcpTransport).requestTimeout(null).sync())
assertThatThrownBy(() -> McpClient.sync(mcpTransport).requestTimeout(null).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Request timeout must not be null");
}
@ -180,10 +180,10 @@ public abstract class BlaAbstractMcpSyncClientTests {
void testInitializeWithRootsListProviders() {
var transport = createMcpTransport();
var client = McpClient.using(transport)
var client = McpClient.sync(transport)
.requestTimeout(TIMEOUT)
.roots(new Root("file:///test/path", "test-root"))
.sync();
.build();
assertThatCode(() -> {
client.initialize();
@ -262,12 +262,12 @@ public abstract class BlaAbstractMcpSyncClientTests {
AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false);
var transport = createMcpTransport();
var client = McpClient.using(transport)
var client = McpClient.sync(transport)
.requestTimeout(TIMEOUT)
.toolsChangeConsumer(tools -> toolsNotificationReceived.set(true))
.resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true))
.promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true))
.sync();
.build();
assertThatCode(() -> {
client.initialize();
@ -295,10 +295,10 @@ public abstract class BlaAbstractMcpSyncClientTests {
AtomicBoolean logReceived = new AtomicBoolean(false);
var transport = createMcpTransport();
var client = McpClient.using(transport)
var client = McpClient.sync(transport)
.requestTimeout(TIMEOUT)
.loggingConsumer(notification -> logReceived.set(true))
.sync();
.build();
assertThatCode(() -> {
client.initialize();

View file

@ -18,16 +18,13 @@ package org.springframework.ai.mcp.server;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import org.springframework.ai.mcp.server.McpServer.PromptRegistration;
import org.springframework.ai.mcp.server.McpServer.ResourceRegistration;
import org.springframework.ai.mcp.server.McpServer.ToolRegistration;
import org.springframework.ai.mcp.spec.McpError;
import org.springframework.ai.mcp.spec.McpSchema;
import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
@ -82,24 +79,24 @@ public abstract class BlaAbstractMcpAsyncServerTests {
@Test
void testConstructorWithInvalidArguments() {
assertThatThrownBy(() -> McpServer.using(null)).isInstanceOf(IllegalArgumentException.class)
assertThatThrownBy(() -> McpServer.async(null)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Transport must not be null");
assertThatThrownBy(() -> McpServer.using(createMcpTransport()).serverInfo((McpSchema.Implementation) null))
assertThatThrownBy(() -> McpServer.async(createMcpTransport()).serverInfo((McpSchema.Implementation) null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Server info must not be null");
}
@Test
void testGracefulShutdown() {
var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async();
var mcpAsyncServer = McpServer.async(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
StepVerifier.create(mcpAsyncServer.closeGracefully()).verifyComplete();
}
@Test
void testImmediateClose() {
var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async();
var mcpAsyncServer = McpServer.async(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
assertThatCode(() -> mcpAsyncServer.close()).doesNotThrowAnyException();
}
@ -118,81 +115,80 @@ public abstract class BlaAbstractMcpAsyncServerTests {
@Test
void testAddTool() {
Tool newTool = new McpSchema.Tool("new-tool", "New test tool", emptyJsonSchema);
var mcpAsyncServer = McpServer.using(createMcpTransport())
var mcpAsyncServer = McpServer.async(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.async();
.build();
StepVerifier
.create(mcpAsyncServer.addTool(new ToolRegistration(newTool, args -> new CallToolResult(List.of(), false))))
StepVerifier.create(mcpAsyncServer.addTool(new McpServerFeatures.AsyncToolRegistration(newTool,
args -> Mono.just(new CallToolResult(List.of(), false)))))
.verifyComplete();
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10)));
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
}
@Test
void testAddDuplicateTool() {
Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema);
var mcpAsyncServer = McpServer.using(createMcpTransport())
var mcpAsyncServer = McpServer.async(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tool(duplicateTool, args -> new CallToolResult(List.of(), false))
.async();
.tool(duplicateTool, args -> Mono.just(new CallToolResult(List.of(), false)))
.build();
StepVerifier
.create(mcpAsyncServer
.addTool(new ToolRegistration(duplicateTool, args -> new CallToolResult(List.of(), false))))
StepVerifier.create(mcpAsyncServer.addTool(new McpServerFeatures.AsyncToolRegistration(duplicateTool,
args -> Mono.just(new CallToolResult(List.of(), false)))))
.verifyErrorSatisfies(error -> {
assertThat(error).isInstanceOf(McpError.class)
.hasMessage("Tool with name '" + TEST_TOOL_NAME + "' already exists");
});
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10)));
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
}
@Test
void testRemoveTool() {
Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema);
var mcpAsyncServer = McpServer.using(createMcpTransport())
var mcpAsyncServer = McpServer.async(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tool(too, args -> new CallToolResult(List.of(), false))
.async();
.tool(too, args -> Mono.just(new CallToolResult(List.of(), false)))
.build();
StepVerifier.create(mcpAsyncServer.removeTool(TEST_TOOL_NAME)).verifyComplete();
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10)));
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
}
@Test
void testRemoveNonexistentTool() {
var mcpAsyncServer = McpServer.using(createMcpTransport())
var mcpAsyncServer = McpServer.async(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.async();
.build();
StepVerifier.create(mcpAsyncServer.removeTool("nonexistent-tool")).verifyErrorSatisfies(error -> {
assertThat(error).isInstanceOf(McpError.class).hasMessage("Tool with name 'nonexistent-tool' not found");
});
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10)));
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
}
@Test
void testNotifyToolsListChanged() {
Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema);
var mcpAsyncServer = McpServer.using(createMcpTransport())
var mcpAsyncServer = McpServer.async(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tool(too, args -> new CallToolResult(List.of(), false))
.async();
.tool(too, args -> Mono.just(new CallToolResult(List.of(), false)))
.build();
StepVerifier.create(mcpAsyncServer.notifyToolsListChanged()).verifyComplete();
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10)));
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
}
// ---------------------------------------
@ -201,24 +197,24 @@ public abstract class BlaAbstractMcpAsyncServerTests {
@Test
void testNotifyResourcesListChanged() {
var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async();
var mcpAsyncServer = McpServer.async(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
StepVerifier.create(mcpAsyncServer.notifyResourcesListChanged()).verifyComplete();
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10)));
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
}
@Test
void testAddResource() {
var mcpAsyncServer = McpServer.using(createMcpTransport())
var mcpAsyncServer = McpServer.async(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().resources(true, false).build())
.async();
.build();
Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description",
null);
ResourceRegistration registration = new ResourceRegistration(resource,
req -> new ReadResourceResult(List.of()));
McpServerFeatures.AsyncResourceRegistration registration = new McpServerFeatures.AsyncResourceRegistration(
resource, req -> Mono.just(new ReadResourceResult(List.of())));
StepVerifier.create(mcpAsyncServer.addResource(registration)).verifyComplete();
@ -227,14 +223,15 @@ public abstract class BlaAbstractMcpAsyncServerTests {
@Test
void testAddResourceWithNullRegistration() {
var mcpAsyncServer = McpServer.using(createMcpTransport())
var mcpAsyncServer = McpServer.async(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().resources(true, false).build())
.async();
.build();
StepVerifier.create(mcpAsyncServer.addResource(null)).verifyErrorSatisfies(error -> {
assertThat(error).isInstanceOf(McpError.class).hasMessage("Resource must not be null");
});
StepVerifier.create(mcpAsyncServer.addResource((McpServerFeatures.AsyncResourceRegistration) null))
.verifyErrorSatisfies(error -> {
assertThat(error).isInstanceOf(McpError.class).hasMessage("Resource must not be null");
});
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
}
@ -242,14 +239,14 @@ public abstract class BlaAbstractMcpAsyncServerTests {
@Test
void testAddResourceWithoutCapability() {
// Create a server without resource capabilities
McpAsyncServer serverWithoutResources = McpServer.using(createMcpTransport())
McpAsyncServer serverWithoutResources = McpServer.async(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.async();
.build();
Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description",
null);
ResourceRegistration registration = new ResourceRegistration(resource,
req -> new ReadResourceResult(List.of()));
McpServerFeatures.AsyncResourceRegistration registration = new McpServerFeatures.AsyncResourceRegistration(
resource, req -> Mono.just(new ReadResourceResult(List.of())));
StepVerifier.create(serverWithoutResources.addResource(registration)).verifyErrorSatisfies(error -> {
assertThat(error).isInstanceOf(McpError.class)
@ -260,9 +257,9 @@ public abstract class BlaAbstractMcpAsyncServerTests {
@Test
void testRemoveResourceWithoutCapability() {
// Create a server without resource capabilities
McpAsyncServer serverWithoutResources = McpServer.using(createMcpTransport())
McpAsyncServer serverWithoutResources = McpServer.async(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.async();
.build();
StepVerifier.create(serverWithoutResources.removeResource(TEST_RESOURCE_URI)).verifyErrorSatisfies(error -> {
assertThat(error).isInstanceOf(McpError.class)
@ -276,36 +273,37 @@ public abstract class BlaAbstractMcpAsyncServerTests {
@Test
void testNotifyPromptsListChanged() {
var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async();
var mcpAsyncServer = McpServer.async(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
StepVerifier.create(mcpAsyncServer.notifyPromptsListChanged()).verifyComplete();
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10)));
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
}
@Test
void testAddPromptWithNullRegistration() {
var mcpAsyncServer = McpServer.using(createMcpTransport())
var mcpAsyncServer = McpServer.async(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().prompts(false).build())
.async();
.build();
StepVerifier.create(mcpAsyncServer.addPrompt(null)).verifyErrorSatisfies(error -> {
assertThat(error).isInstanceOf(McpError.class).hasMessage("Prompt registration must not be null");
});
StepVerifier.create(mcpAsyncServer.addPrompt((McpServerFeatures.AsyncPromptRegistration) null))
.verifyErrorSatisfies(error -> {
assertThat(error).isInstanceOf(McpError.class).hasMessage("Prompt registration must not be null");
});
}
@Test
void testAddPromptWithoutCapability() {
// Create a server without prompt capabilities
McpAsyncServer serverWithoutPrompts = McpServer.using(createMcpTransport())
McpAsyncServer serverWithoutPrompts = McpServer.async(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.async();
.build();
Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of());
PromptRegistration registration = new PromptRegistration(prompt, req -> new GetPromptResult(
"Test prompt description",
List.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content")))));
McpServerFeatures.AsyncPromptRegistration registration = new McpServerFeatures.AsyncPromptRegistration(prompt,
req -> Mono.just(new GetPromptResult("Test prompt description", List
.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))));
StepVerifier.create(serverWithoutPrompts.addPrompt(registration)).verifyErrorSatisfies(error -> {
assertThat(error).isInstanceOf(McpError.class)
@ -316,9 +314,9 @@ public abstract class BlaAbstractMcpAsyncServerTests {
@Test
void testRemovePromptWithoutCapability() {
// Create a server without prompt capabilities
McpAsyncServer serverWithoutPrompts = McpServer.using(createMcpTransport())
McpAsyncServer serverWithoutPrompts = McpServer.async(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.async();
.build();
StepVerifier.create(serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)).verifyErrorSatisfies(error -> {
assertThat(error).isInstanceOf(McpError.class)
@ -331,15 +329,15 @@ public abstract class BlaAbstractMcpAsyncServerTests {
String TEST_PROMPT_NAME_TO_REMOVE = "TEST_PROMPT_NAME678";
Prompt prompt = new Prompt(TEST_PROMPT_NAME_TO_REMOVE, "Test Prompt", List.of());
PromptRegistration registration = new PromptRegistration(prompt, req -> new GetPromptResult(
"Test prompt description",
List.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content")))));
McpServerFeatures.AsyncPromptRegistration registration = new McpServerFeatures.AsyncPromptRegistration(prompt,
req -> Mono.just(new GetPromptResult("Test prompt description", List
.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))));
var mcpAsyncServer = McpServer.using(createMcpTransport())
var mcpAsyncServer = McpServer.async(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().prompts(true).build())
.prompts(registration)
.async();
.build();
StepVerifier.create(mcpAsyncServer.removePrompt(TEST_PROMPT_NAME_TO_REMOVE)).verifyComplete();
@ -348,10 +346,10 @@ public abstract class BlaAbstractMcpAsyncServerTests {
@Test
void testRemoveNonexistentPrompt() {
var mcpAsyncServer2 = McpServer.using(createMcpTransport())
var mcpAsyncServer2 = McpServer.async(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().prompts(true).build())
.async();
.build();
StepVerifier.create(mcpAsyncServer2.removePrompt("nonexistent-prompt")).verifyErrorSatisfies(error -> {
assertThat(error).isInstanceOf(McpError.class)
@ -372,15 +370,15 @@ public abstract class BlaAbstractMcpAsyncServerTests {
var rootsReceived = new McpSchema.Root[1];
var consumerCalled = new boolean[1];
var singleConsumerServer = McpServer.using(createMcpTransport())
var singleConsumerServer = McpServer.async(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.rootsChangeConsumers(List.of(roots -> {
.rootsChangeConsumers(List.of(roots -> Mono.fromRunnable(() -> {
consumerCalled[0] = true;
if (!roots.isEmpty()) {
rootsReceived[0] = roots.get(0);
}
}))
.async();
})))
.build();
assertThat(singleConsumerServer).isNotNull();
assertThatCode(() -> singleConsumerServer.closeGracefully().block(Duration.ofSeconds(10)))
@ -392,13 +390,13 @@ public abstract class BlaAbstractMcpAsyncServerTests {
var consumer2Called = new boolean[1];
var rootsContent = new List[1];
var multipleConsumersServer = McpServer.using(createMcpTransport())
var multipleConsumersServer = McpServer.async(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.rootsChangeConsumers(List.of(roots -> {
.rootsChangeConsumers(List.of(roots -> Mono.fromRunnable(() -> {
consumer1Called[0] = true;
rootsContent[0] = roots;
}, roots -> consumer2Called[0] = true))
.async();
}), roots -> Mono.fromRunnable(() -> consumer2Called[0] = true)))
.build();
assertThat(multipleConsumersServer).isNotNull();
assertThatCode(() -> multipleConsumersServer.closeGracefully().block(Duration.ofSeconds(10)))
@ -406,12 +404,12 @@ public abstract class BlaAbstractMcpAsyncServerTests {
onClose();
// Test error handling
var errorHandlingServer = McpServer.using(createMcpTransport())
var errorHandlingServer = McpServer.async(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.rootsChangeConsumers(List.of(roots -> {
throw new RuntimeException("Test error");
}))
.async();
.build();
assertThat(errorHandlingServer).isNotNull();
assertThatCode(() -> errorHandlingServer.closeGracefully().block(Duration.ofSeconds(10)))
@ -419,7 +417,7 @@ public abstract class BlaAbstractMcpAsyncServerTests {
onClose();
// Test without consumers
var noConsumersServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async();
var noConsumersServer = McpServer.async(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
assertThat(noConsumersServer).isNotNull();
assertThatCode(() -> noConsumersServer.closeGracefully().block(Duration.ofSeconds(10)))
@ -432,10 +430,10 @@ public abstract class BlaAbstractMcpAsyncServerTests {
@Test
void testLoggingLevels() {
var mcpAsyncServer = McpServer.using(createMcpTransport())
var mcpAsyncServer = McpServer.async(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().logging().build())
.async();
.build();
// Test all logging levels
for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
@ -451,10 +449,10 @@ public abstract class BlaAbstractMcpAsyncServerTests {
@Test
void testLoggingWithoutCapability() {
var mcpAsyncServer = McpServer.using(createMcpTransport())
var mcpAsyncServer = McpServer.async(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().build()) // No logging capability
.async();
.build();
var notification = McpSchema.LoggingMessageNotification.builder()
.level(McpSchema.LoggingLevel.INFO)
@ -467,10 +465,10 @@ public abstract class BlaAbstractMcpAsyncServerTests {
@Test
void testLoggingWithNullNotification() {
var mcpAsyncServer = McpServer.using(createMcpTransport())
var mcpAsyncServer = McpServer.async(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().logging().build())
.async();
.build();
StepVerifier.create(mcpAsyncServer.loggingNotification(null)).verifyError(McpError.class);
}

View file

@ -23,9 +23,6 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.mcp.server.McpServer.PromptRegistration;
import org.springframework.ai.mcp.server.McpServer.ResourceRegistration;
import org.springframework.ai.mcp.server.McpServer.ToolRegistration;
import org.springframework.ai.mcp.spec.McpError;
import org.springframework.ai.mcp.spec.McpSchema;
import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
@ -81,31 +78,31 @@ public abstract class BlaAbstractMcpSyncServerTests {
@Test
void testConstructorWithInvalidArguments() {
assertThatThrownBy(() -> McpServer.using(null)).isInstanceOf(IllegalArgumentException.class)
assertThatThrownBy(() -> McpServer.sync(null)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Transport must not be null");
assertThatThrownBy(() -> McpServer.using(createMcpTransport()).serverInfo((McpSchema.Implementation) null))
assertThatThrownBy(() -> McpServer.sync(createMcpTransport()).serverInfo(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Server info must not be null");
}
@Test
void testGracefulShutdown() {
var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
}
@Test
void testImmediateClose() {
var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
assertThatCode(() -> mcpSyncServer.close()).doesNotThrowAnyException();
}
@Test
void testGetAsyncServer() {
var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
assertThat(mcpSyncServer.getAsyncServer()).isNotNull();
@ -126,14 +123,14 @@ public abstract class BlaAbstractMcpSyncServerTests {
@Test
void testAddTool() {
var mcpSyncServer = McpServer.using(createMcpTransport())
var mcpSyncServer = McpServer.sync(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.sync();
.build();
Tool newTool = new McpSchema.Tool("new-tool", "New test tool", emptyJsonSchema);
assertThatCode(() -> mcpSyncServer
.addTool(new ToolRegistration(newTool, args -> new CallToolResult(List.of(), false))))
.addTool(new McpServerFeatures.SyncToolRegistration(newTool, args -> new CallToolResult(List.of(), false))))
.doesNotThrowAnyException();
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
@ -143,14 +140,14 @@ public abstract class BlaAbstractMcpSyncServerTests {
void testAddDuplicateTool() {
Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema);
var mcpSyncServer = McpServer.using(createMcpTransport())
var mcpSyncServer = McpServer.sync(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tool(duplicateTool, args -> new CallToolResult(List.of(), false))
.sync();
.build();
assertThatThrownBy(() -> mcpSyncServer
.addTool(new ToolRegistration(duplicateTool, args -> new CallToolResult(List.of(), false))))
assertThatThrownBy(() -> mcpSyncServer.addTool(new McpServerFeatures.SyncToolRegistration(duplicateTool,
args -> new CallToolResult(List.of(), false))))
.isInstanceOf(McpError.class)
.hasMessage("Tool with name '" + TEST_TOOL_NAME + "' already exists");
@ -161,11 +158,11 @@ public abstract class BlaAbstractMcpSyncServerTests {
void testRemoveTool() {
Tool tool = new McpSchema.Tool(TEST_TOOL_NAME, "Test tool", emptyJsonSchema);
var mcpSyncServer = McpServer.using(createMcpTransport())
var mcpSyncServer = McpServer.sync(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tool(tool, args -> new CallToolResult(List.of(), false))
.sync();
.build();
assertThatCode(() -> mcpSyncServer.removeTool(TEST_TOOL_NAME)).doesNotThrowAnyException();
@ -174,10 +171,10 @@ public abstract class BlaAbstractMcpSyncServerTests {
@Test
void testRemoveNonexistentTool() {
var mcpSyncServer = McpServer.using(createMcpTransport())
var mcpSyncServer = McpServer.sync(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.sync();
.build();
assertThatThrownBy(() -> mcpSyncServer.removeTool("nonexistent-tool")).isInstanceOf(McpError.class)
.hasMessage("Tool with name 'nonexistent-tool' not found");
@ -187,7 +184,7 @@ public abstract class BlaAbstractMcpSyncServerTests {
@Test
void testNotifyToolsListChanged() {
var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
assertThatCode(() -> mcpSyncServer.notifyToolsListChanged()).doesNotThrowAnyException();
@ -200,7 +197,7 @@ public abstract class BlaAbstractMcpSyncServerTests {
@Test
void testNotifyResourcesListChanged() {
var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
assertThatCode(() -> mcpSyncServer.notifyResourcesListChanged()).doesNotThrowAnyException();
@ -209,15 +206,15 @@ public abstract class BlaAbstractMcpSyncServerTests {
@Test
void testAddResource() {
var mcpSyncServer = McpServer.using(createMcpTransport())
var mcpSyncServer = McpServer.sync(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().resources(true, false).build())
.sync();
.build();
Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description",
null);
ResourceRegistration registration = new ResourceRegistration(resource,
req -> new ReadResourceResult(List.of()));
McpServerFeatures.SyncResourceRegistration registration = new McpServerFeatures.SyncResourceRegistration(
resource, req -> new ReadResourceResult(List.of()));
assertThatCode(() -> mcpSyncServer.addResource(registration)).doesNotThrowAnyException();
@ -226,12 +223,13 @@ public abstract class BlaAbstractMcpSyncServerTests {
@Test
void testAddResourceWithNullRegistration() {
var mcpSyncServer = McpServer.using(createMcpTransport())
var mcpSyncServer = McpServer.sync(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().resources(true, false).build())
.sync();
.build();
assertThatThrownBy(() -> mcpSyncServer.addResource(null)).isInstanceOf(McpError.class)
assertThatThrownBy(() -> mcpSyncServer.addResource((McpServerFeatures.SyncResourceRegistration) null))
.isInstanceOf(McpError.class)
.hasMessage("Resource must not be null");
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
@ -239,12 +237,12 @@ public abstract class BlaAbstractMcpSyncServerTests {
@Test
void testAddResourceWithoutCapability() {
var serverWithoutResources = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
var serverWithoutResources = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description",
null);
ResourceRegistration registration = new ResourceRegistration(resource,
req -> new ReadResourceResult(List.of()));
McpServerFeatures.SyncResourceRegistration registration = new McpServerFeatures.SyncResourceRegistration(
resource, req -> new ReadResourceResult(List.of()));
assertThatThrownBy(() -> serverWithoutResources.addResource(registration)).isInstanceOf(McpError.class)
.hasMessage("Server must be configured with resource capabilities");
@ -252,7 +250,7 @@ public abstract class BlaAbstractMcpSyncServerTests {
@Test
void testRemoveResourceWithoutCapability() {
var serverWithoutResources = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
var serverWithoutResources = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
assertThatThrownBy(() -> serverWithoutResources.removeResource(TEST_RESOURCE_URI)).isInstanceOf(McpError.class)
.hasMessage("Server must be configured with resource capabilities");
@ -264,7 +262,7 @@ public abstract class BlaAbstractMcpSyncServerTests {
@Test
void testNotifyPromptsListChanged() {
var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
assertThatCode(() -> mcpSyncServer.notifyPromptsListChanged()).doesNotThrowAnyException();
@ -273,23 +271,24 @@ public abstract class BlaAbstractMcpSyncServerTests {
@Test
void testAddPromptWithNullRegistration() {
var mcpSyncServer = McpServer.using(createMcpTransport())
var mcpSyncServer = McpServer.sync(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().prompts(false).build())
.sync();
.build();
assertThatThrownBy(() -> mcpSyncServer.addPrompt(null)).isInstanceOf(McpError.class)
assertThatThrownBy(() -> mcpSyncServer.addPrompt((McpServerFeatures.SyncPromptRegistration) null))
.isInstanceOf(McpError.class)
.hasMessage("Prompt registration must not be null");
}
@Test
void testAddPromptWithoutCapability() {
var serverWithoutPrompts = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
var serverWithoutPrompts = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of());
PromptRegistration registration = new PromptRegistration(prompt, req -> new GetPromptResult(
"Test prompt description",
List.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content")))));
McpServerFeatures.SyncPromptRegistration registration = new McpServerFeatures.SyncPromptRegistration(prompt,
req -> new GetPromptResult("Test prompt description", List
.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content")))));
assertThatThrownBy(() -> serverWithoutPrompts.addPrompt(registration)).isInstanceOf(McpError.class)
.hasMessage("Server must be configured with prompt capabilities");
@ -297,7 +296,7 @@ public abstract class BlaAbstractMcpSyncServerTests {
@Test
void testRemovePromptWithoutCapability() {
var serverWithoutPrompts = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
var serverWithoutPrompts = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
assertThatThrownBy(() -> serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)).isInstanceOf(McpError.class)
.hasMessage("Server must be configured with prompt capabilities");
@ -306,15 +305,15 @@ public abstract class BlaAbstractMcpSyncServerTests {
@Test
void testRemovePrompt() {
Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of());
PromptRegistration registration = new PromptRegistration(prompt, req -> new GetPromptResult(
"Test prompt description",
List.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content")))));
McpServerFeatures.SyncPromptRegistration registration = new McpServerFeatures.SyncPromptRegistration(prompt,
req -> new GetPromptResult("Test prompt description", List
.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content")))));
var mcpSyncServer = McpServer.using(createMcpTransport())
var mcpSyncServer = McpServer.sync(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().prompts(true).build())
.prompts(registration)
.sync();
.build();
assertThatCode(() -> mcpSyncServer.removePrompt(TEST_PROMPT_NAME)).doesNotThrowAnyException();
@ -323,10 +322,10 @@ public abstract class BlaAbstractMcpSyncServerTests {
@Test
void testRemoveNonexistentPrompt() {
var mcpSyncServer = McpServer.using(createMcpTransport())
var mcpSyncServer = McpServer.sync(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().prompts(true).build())
.sync();
.build();
assertThatThrownBy(() -> mcpSyncServer.removePrompt("nonexistent-prompt")).isInstanceOf(McpError.class)
.hasMessage("Prompt with name 'nonexistent-prompt' not found");
@ -344,7 +343,7 @@ public abstract class BlaAbstractMcpSyncServerTests {
var rootsReceived = new McpSchema.Root[1];
var consumerCalled = new boolean[1];
var singleConsumerServer = McpServer.using(createMcpTransport())
var singleConsumerServer = McpServer.sync(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.rootsChangeConsumers(List.of(roots -> {
consumerCalled[0] = true;
@ -352,7 +351,7 @@ public abstract class BlaAbstractMcpSyncServerTests {
rootsReceived[0] = roots.get(0);
}
}))
.sync();
.build();
assertThat(singleConsumerServer).isNotNull();
assertThatCode(() -> singleConsumerServer.closeGracefully()).doesNotThrowAnyException();
@ -363,32 +362,32 @@ public abstract class BlaAbstractMcpSyncServerTests {
var consumer2Called = new boolean[1];
var rootsContent = new List[1];
var multipleConsumersServer = McpServer.using(createMcpTransport())
var multipleConsumersServer = McpServer.sync(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.rootsChangeConsumers(List.of(roots -> {
consumer1Called[0] = true;
rootsContent[0] = roots;
}, roots -> consumer2Called[0] = true))
.sync();
.build();
assertThat(multipleConsumersServer).isNotNull();
assertThatCode(() -> multipleConsumersServer.closeGracefully()).doesNotThrowAnyException();
onClose();
// Test error handling
var errorHandlingServer = McpServer.using(createMcpTransport())
var errorHandlingServer = McpServer.sync(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.rootsChangeConsumers(List.of(roots -> {
throw new RuntimeException("Test error");
}))
.sync();
.build();
assertThat(errorHandlingServer).isNotNull();
assertThatCode(() -> errorHandlingServer.closeGracefully()).doesNotThrowAnyException();
onClose();
// Test without consumers
var noConsumersServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
var noConsumersServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
assertThat(noConsumersServer).isNotNull();
assertThatCode(() -> noConsumersServer.closeGracefully()).doesNotThrowAnyException();
@ -400,10 +399,10 @@ public abstract class BlaAbstractMcpSyncServerTests {
@Test
void testLoggingLevels() {
var mcpSyncServer = McpServer.using(createMcpTransport())
var mcpSyncServer = McpServer.sync(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().logging().build())
.sync();
.build();
// Test all logging levels
for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
@ -419,10 +418,10 @@ public abstract class BlaAbstractMcpSyncServerTests {
@Test
void testLoggingWithoutCapability() {
var mcpSyncServer = McpServer.using(createMcpTransport())
var mcpSyncServer = McpServer.sync(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().build()) // No logging capability
.sync();
.build();
var notification = McpSchema.LoggingMessageNotification.builder()
.level(McpSchema.LoggingLevel.INFO)
@ -435,10 +434,10 @@ public abstract class BlaAbstractMcpSyncServerTests {
@Test
void testLoggingWithNullNotification() {
var mcpSyncServer = McpServer.using(createMcpTransport())
var mcpSyncServer = McpServer.sync(createMcpTransport())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().logging().build())
.sync();
.build();
assertThatThrownBy(() -> mcpSyncServer.loggingNotification(null)).isInstanceOf(McpError.class);
}

View file

@ -36,7 +36,7 @@ import org.springframework.ai.mcp.client.McpClient;
import org.springframework.ai.mcp.client.transport.HttpClientSseClientTransport;
import org.springframework.ai.mcp.client.transport.WebFluxSseClientTransport;
import org.springframework.ai.mcp.server.McpServer;
import org.springframework.ai.mcp.server.McpServer.ToolRegistration;
import org.springframework.ai.mcp.server.McpServerFeatures;
import org.springframework.ai.mcp.server.transport.WebFluxSseServerTransport;
import org.springframework.ai.mcp.spec.McpError;
import org.springframework.ai.mcp.spec.McpSchema;
@ -69,7 +69,7 @@ public class WebFluxSseIntegrationTests {
private WebFluxSseServerTransport mcpServerTransport;
ConcurrentHashMap<String, McpClient.Builder> clientBulders = new ConcurrentHashMap<>();
ConcurrentHashMap<String, McpClient.SyncSpec> clientBulders = new ConcurrentHashMap<>();
@BeforeEach
public void before() {
@ -80,9 +80,9 @@ public class WebFluxSseIntegrationTests {
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
this.httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow();
clientBulders.put("httpclient", McpClient.using(new HttpClientSseClientTransport("http://localhost:" + PORT)));
clientBulders.put("webflux", McpClient
.using(new WebFluxSseClientTransport(WebClient.builder().baseUrl("http://localhost:" + PORT))));
clientBulders.put("httpclient", McpClient.sync(new HttpClientSseClientTransport("http://localhost:" + PORT)));
clientBulders.put("webflux",
McpClient.sync(new WebFluxSseClientTransport(WebClient.builder().baseUrl("http://localhost:" + PORT))));
}
@ -98,7 +98,7 @@ public class WebFluxSseIntegrationTests {
// ---------------------------------------
@Test
void testCreateMessageWithoutInitialization() {
var mcpAsyncServer = McpServer.using(mcpServerTransport).serverInfo("test-server", "1.0.0").async();
var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build();
var messages = List
.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message")));
@ -117,11 +117,11 @@ public class WebFluxSseIntegrationTests {
@ValueSource(strings = { "httpclient", "webflux" })
void testCreateMessageWithoutSamplingCapabilities(String clientType) {
var mcpAsyncServer = McpServer.using(mcpServerTransport).serverInfo("test-server", "1.0.0").async();
var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build();
var clientBuilder = clientBulders.get(clientType);
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")).sync();
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")).build();
InitializeResult initResult = client.initialize();
assertThat(initResult).isNotNull();
@ -145,7 +145,7 @@ public class WebFluxSseIntegrationTests {
var clientBuilder = clientBulders.get(clientType);
var mcpAsyncServer = McpServer.using(mcpServerTransport).serverInfo("test-server", "1.0.0").async();
var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build();
Function<CreateMessageRequest, CreateMessageResult> samplingHandler = request -> {
assertThat(request.messages()).hasSize(1);
@ -158,7 +158,7 @@ public class WebFluxSseIntegrationTests {
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
.capabilities(ClientCapabilities.builder().sampling().build())
.sampling(samplingHandler)
.sync();
.build();
InitializeResult initResult = client.initialize();
assertThat(initResult).isNotNull();
@ -191,13 +191,13 @@ public class WebFluxSseIntegrationTests {
List<Root> roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2"));
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
var mcpServer = McpServer.using(mcpServerTransport)
var mcpServer = McpServer.sync(mcpServerTransport)
.rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate))
.sync();
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(roots)
.sync();
.build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@ -236,14 +236,14 @@ public class WebFluxSseIntegrationTests {
void testRootsWithoutCapability(String clientType) {
var clientBuilder = clientBulders.get(clientType);
var mcpServer = McpServer.using(mcpServerTransport).rootsChangeConsumer(rootsUpdate -> {
}).sync();
var mcpServer = McpServer.sync(mcpServerTransport).rootsChangeConsumer(rootsUpdate -> {
}).build();
// Create client without roots capability
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()) // No
// roots
// capability
.sync();
.build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@ -262,13 +262,13 @@ public class WebFluxSseIntegrationTests {
var clientBuilder = clientBulders.get(clientType);
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
var mcpServer = McpServer.using(mcpServerTransport)
var mcpServer = McpServer.sync(mcpServerTransport)
.rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate))
.sync();
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(List.of()) // Empty roots list
.sync();
.build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@ -293,14 +293,14 @@ public class WebFluxSseIntegrationTests {
AtomicReference<List<Root>> rootsRef1 = new AtomicReference<>();
AtomicReference<List<Root>> rootsRef2 = new AtomicReference<>();
var mcpServer = McpServer.using(mcpServerTransport)
var mcpServer = McpServer.sync(mcpServerTransport)
.rootsChangeConsumer(rootsUpdate -> rootsRef1.set(rootsUpdate))
.rootsChangeConsumer(rootsUpdate -> rootsRef2.set(rootsUpdate))
.sync();
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(roots)
.sync();
.build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@ -325,13 +325,13 @@ public class WebFluxSseIntegrationTests {
List<Root> roots = List.of(new Root("uri1://", "root1"));
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
var mcpServer = McpServer.using(mcpServerTransport)
var mcpServer = McpServer.sync(mcpServerTransport)
.rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate))
.sync();
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(roots)
.sync();
.build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@ -368,8 +368,8 @@ public class WebFluxSseIntegrationTests {
var clientBuilder = clientBulders.get(clientType);
var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
ToolRegistration tool1 = new ToolRegistration(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema),
request -> {
McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration(
new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), request -> {
// perform a blocking call to a remote service
String response = RestClient.create()
.get()
@ -380,12 +380,12 @@ public class WebFluxSseIntegrationTests {
return callResponse;
});
var mcpServer = McpServer.using(mcpServerTransport)
var mcpServer = McpServer.sync(mcpServerTransport)
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool1)
.sync();
.build();
var mcpClient = clientBuilder.sync();
var mcpClient = clientBuilder.build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@ -408,8 +408,8 @@ public class WebFluxSseIntegrationTests {
var clientBuilder = clientBulders.get(clientType);
var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
ToolRegistration tool1 = new ToolRegistration(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema),
request -> {
McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration(
new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), request -> {
// perform a blocking call to a remote service
String response = RestClient.create()
.get()
@ -420,10 +420,10 @@ public class WebFluxSseIntegrationTests {
return callResponse;
});
var mcpServer = McpServer.using(mcpServerTransport)
var mcpServer = McpServer.sync(mcpServerTransport)
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool1)
.sync();
.build();
AtomicReference<List<Tool>> rootsRef = new AtomicReference<>();
var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> {
@ -435,7 +435,7 @@ public class WebFluxSseIntegrationTests {
.body(String.class);
assertThat(response).isNotBlank();
rootsRef.set(toolsUpdate);
}).sync();
}).build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@ -458,8 +458,8 @@ public class WebFluxSseIntegrationTests {
});
// Add a new tool
ToolRegistration tool2 = new ToolRegistration(new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema),
request -> callResponse);
McpServerFeatures.SyncToolRegistration tool2 = new McpServerFeatures.SyncToolRegistration(
new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema), request -> callResponse);
mcpServer.addTool(tool2);

View file

@ -35,7 +35,6 @@ import reactor.test.StepVerifier;
import org.springframework.ai.mcp.client.McpClient;
import org.springframework.ai.mcp.client.transport.HttpClientSseClientTransport;
import org.springframework.ai.mcp.server.McpServer.ToolRegistration;
import org.springframework.ai.mcp.server.transport.WebMvcSseServerTransport;
import org.springframework.ai.mcp.spec.McpError;
import org.springframework.ai.mcp.spec.McpSchema;
@ -71,7 +70,7 @@ public class WebMvcSseIntegrationTests {
private WebMvcSseServerTransport mcpServerTransport;
McpClient.Builder clientBuilder;
McpClient.SyncSpec clientBuilder;
@Configuration
@EnableWebMvc
@ -137,7 +136,7 @@ public class WebMvcSseIntegrationTests {
throw new RuntimeException("Failed to start Tomcat", e);
}
this.clientBuilder = McpClient.using(new HttpClientSseClientTransport("http://localhost:" + PORT));
this.clientBuilder = McpClient.sync(new HttpClientSseClientTransport("http://localhost:" + PORT));
}
@AfterEach
@ -164,7 +163,7 @@ public class WebMvcSseIntegrationTests {
// ---------------------------------------
@Test
void testCreateMessageWithoutInitialization() {
var mcpAsyncServer = McpServer.using(mcpServerTransport).serverInfo("test-server", "1.0.0").async();
var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build();
var messages = List
.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message")));
@ -182,9 +181,9 @@ public class WebMvcSseIntegrationTests {
@Test
void testCreateMessageWithoutSamplingCapabilities() {
var mcpAsyncServer = McpServer.using(mcpServerTransport).serverInfo("test-server", "1.0.0").async();
var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build();
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")).sync();
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")).build();
InitializeResult initResult = client.initialize();
assertThat(initResult).isNotNull();
@ -205,7 +204,7 @@ public class WebMvcSseIntegrationTests {
@Test
void testCreateMessageSuccess() throws InterruptedException {
var mcpAsyncServer = McpServer.using(mcpServerTransport).serverInfo("test-server", "1.0.0").async();
var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build();
Function<CreateMessageRequest, CreateMessageResult> samplingHandler = request -> {
assertThat(request.messages()).hasSize(1);
@ -218,7 +217,7 @@ public class WebMvcSseIntegrationTests {
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
.capabilities(ClientCapabilities.builder().sampling().build())
.sampling(samplingHandler)
.sync();
.build();
InitializeResult initResult = client.initialize();
assertThat(initResult).isNotNull();
@ -248,13 +247,13 @@ public class WebMvcSseIntegrationTests {
List<Root> roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2"));
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
var mcpServer = McpServer.using(mcpServerTransport)
var mcpServer = McpServer.sync(mcpServerTransport)
.rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate))
.sync();
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(roots)
.sync();
.build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@ -290,12 +289,12 @@ public class WebMvcSseIntegrationTests {
@Test
void testRootsWithoutCapability() {
var mcpServer = McpServer.using(mcpServerTransport).rootsChangeConsumer(rootsUpdate -> {
}).sync();
var mcpServer = McpServer.sync(mcpServerTransport).rootsChangeConsumer(rootsUpdate -> {
}).build();
// Create client without roots capability
// No roots capability
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()).sync();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()).build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@ -311,13 +310,13 @@ public class WebMvcSseIntegrationTests {
@Test
void testRootsWithEmptyRootsList() {
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
var mcpServer = McpServer.using(mcpServerTransport)
var mcpServer = McpServer.sync(mcpServerTransport)
.rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate))
.sync();
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(List.of()) // Empty roots list
.sync();
.build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@ -339,14 +338,14 @@ public class WebMvcSseIntegrationTests {
AtomicReference<List<Root>> rootsRef1 = new AtomicReference<>();
AtomicReference<List<Root>> rootsRef2 = new AtomicReference<>();
var mcpServer = McpServer.using(mcpServerTransport)
var mcpServer = McpServer.sync(mcpServerTransport)
.rootsChangeConsumer(rootsUpdate -> rootsRef1.set(rootsUpdate))
.rootsChangeConsumer(rootsUpdate -> rootsRef2.set(rootsUpdate))
.sync();
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(roots)
.sync();
.build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@ -367,13 +366,13 @@ public class WebMvcSseIntegrationTests {
List<Root> roots = List.of(new Root("uri1://", "root1"));
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
var mcpServer = McpServer.using(mcpServerTransport)
var mcpServer = McpServer.sync(mcpServerTransport)
.rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate))
.sync();
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(roots)
.sync();
.build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@ -407,8 +406,8 @@ public class WebMvcSseIntegrationTests {
void testToolCallSuccess() {
var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
ToolRegistration tool1 = new ToolRegistration(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema),
request -> {
McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration(
new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), request -> {
// perform a blocking call to a remote service
String response = RestClient.create()
.get()
@ -419,12 +418,12 @@ public class WebMvcSseIntegrationTests {
return callResponse;
});
var mcpServer = McpServer.using(mcpServerTransport)
var mcpServer = McpServer.sync(mcpServerTransport)
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool1)
.sync();
.build();
var mcpClient = clientBuilder.sync();
var mcpClient = clientBuilder.build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@ -444,8 +443,8 @@ public class WebMvcSseIntegrationTests {
void testToolListChangeHandlingSuccess() {
var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
ToolRegistration tool1 = new ToolRegistration(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema),
request -> {
McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration(
new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), request -> {
// perform a blocking call to a remote service
String response = RestClient.create()
.get()
@ -456,10 +455,10 @@ public class WebMvcSseIntegrationTests {
return callResponse;
});
var mcpServer = McpServer.using(mcpServerTransport)
var mcpServer = McpServer.sync(mcpServerTransport)
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool1)
.sync();
.build();
AtomicReference<List<Tool>> rootsRef = new AtomicReference<>();
var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> {
@ -471,7 +470,7 @@ public class WebMvcSseIntegrationTests {
.body(String.class);
assertThat(response).isNotBlank();
rootsRef.set(toolsUpdate);
}).sync();
}).build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@ -494,8 +493,8 @@ public class WebMvcSseIntegrationTests {
});
// Add a new tool
ToolRegistration tool2 = new ToolRegistration(new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema),
request -> callResponse);
McpServerFeatures.SyncToolRegistration tool2 = new McpServerFeatures.SyncToolRegistration(
new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema), request -> callResponse);
mcpServer.addTool(tool2);
@ -510,9 +509,9 @@ public class WebMvcSseIntegrationTests {
@Test
void testInitialize() {
var mcpServer = McpServer.using(mcpServerTransport).sync();
var mcpServer = McpServer.sync(mcpServerTransport).build();
var mcpClient = clientBuilder.sync();
var mcpClient = clientBuilder.build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();

View file

@ -27,6 +27,7 @@ import java.util.function.Function;
import com.fasterxml.jackson.core.type.TypeReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
@ -55,10 +56,39 @@ import org.springframework.ai.mcp.util.Utils;
/**
* The Model Context Protocol (MCP) client implementation that provides asynchronous
* communication with MCP servers.
* communication with MCP servers using Project Reactor's Mono and Flux types.
*
* <p>
* This client implements the MCP specification, enabling AI models to interact with
* external tools and resources through a standardized interface. Key features include:
* <ul>
* <li>Asynchronous communication using reactive programming patterns
* <li>Tool discovery and invocation for server-provided functionality
* <li>Resource access and management with URI-based addressing
* <li>Prompt template handling for standardized AI interactions
* <li>Real-time notifications for tools, resources, and prompts changes
* <li>Structured logging with configurable severity levels
* <li>Message sampling for AI model interactions
* </ul>
*
* <p>
* The client follows a lifecycle:
* <ol>
* <li>Initialization - Establishes connection and negotiates capabilities
* <li>Normal Operation - Handles requests and notifications
* <li>Graceful Shutdown - Ensures clean connection termination
* </ol>
*
* <p>
* This implementation uses Project Reactor for non-blocking operations, making it
* suitable for high-throughput scenarios and reactive applications. All operations return
* Mono or Flux types that can be composed into reactive pipelines.
*
* @author Dariusz Jędrzejczyk
* @author Christian Tzolov
* @see McpClient
* @see McpSchema
* @see DefaultMcpSession
*/
public class McpAsyncClient {
@ -109,13 +139,96 @@ public class McpAsyncClient {
* request text or image-based interactions and optionally include context from MCP
* servers in their prompts.
*/
private Function<CreateMessageRequest, CreateMessageResult> samplingHandler;
private Function<CreateMessageRequest, Mono<CreateMessageResult>> samplingHandler;
/**
* Client transport implementation.
*/
private final McpTransport transport;
/**
* Create a new McpAsyncClient with the given transport and session request-response
* timeout.
* @param transport the transport to use.
* @param requestTimeout the session request-response timeout.
* @param features the MCP Client supported features.
*/
McpAsyncClient(ClientMcpTransport transport, Duration requestTimeout, McpClientFeatures.Async features) {
Assert.notNull(transport, "Transport must not be null");
Assert.notNull(requestTimeout, "Request timeout must not be null");
this.clientInfo = features.clientInfo();
this.clientCapabilities = features.clientCapabilities();
this.transport = transport;
this.roots = new ConcurrentHashMap<>(features.roots());
// Request Handlers
Map<String, RequestHandler<?>> requestHandlers = new HashMap<>();
// Roots List Request Handler
if (this.clientCapabilities.roots() != null) {
requestHandlers.put(McpSchema.METHOD_ROOTS_LIST, rootsListRequestHandler());
}
// Sampling Handler
if (this.clientCapabilities.sampling() != null) {
if (features.samplingHandler() == null) {
throw new McpError("Sampling handler must not be null when client capabilities include sampling");
}
this.samplingHandler = features.samplingHandler();
requestHandlers.put(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, samplingCreateMessageHandler());
}
// Notification Handlers
Map<String, NotificationHandler> notificationHandlers = new HashMap<>();
// Tools Change Notification
List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumersFinal = new ArrayList<>();
toolsChangeConsumersFinal
.add((notification) -> Mono.fromRunnable(() -> logger.info("Tools changed: {}", notification)));
if (!Utils.isEmpty(features.toolsChangeConsumers())) {
toolsChangeConsumersFinal.addAll(features.toolsChangeConsumers());
}
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED,
asyncToolsChangeNotificationHandler(toolsChangeConsumersFinal));
// Resources Change Notification
List<Function<List<McpSchema.Resource>, Mono<Void>>> resourcesChangeConsumersFinal = new ArrayList<>();
resourcesChangeConsumersFinal
.add((notification) -> Mono.fromRunnable(() -> logger.info("Resources changed: {}", notification)));
if (!Utils.isEmpty(features.resourcesChangeConsumers())) {
resourcesChangeConsumersFinal.addAll(features.resourcesChangeConsumers());
}
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED,
asyncResourcesChangeNotificationHandler(resourcesChangeConsumersFinal));
// Prompts Change Notification
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumersFinal = new ArrayList<>();
promptsChangeConsumersFinal
.add((notification) -> Mono.fromRunnable(() -> logger.info("Prompts changed: {}", notification)));
if (!Utils.isEmpty(features.promptsChangeConsumers())) {
promptsChangeConsumersFinal.addAll(features.promptsChangeConsumers());
}
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED,
asyncPromptsChangeNotificationHandler(promptsChangeConsumersFinal));
// Utility Logging Notification
List<Function<LoggingMessageNotification, Mono<Void>>> loggingConsumersFinal = new ArrayList<>();
loggingConsumersFinal.add((notification) -> Mono.fromRunnable(() -> logger.info("Logging: {}", notification)));
if (!Utils.isEmpty(features.loggingConsumers())) {
loggingConsumersFinal.addAll(features.loggingConsumers());
}
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_MESSAGE,
asyncLoggingNotificationHandler(loggingConsumersFinal));
this.mcpSession = new DefaultMcpSession(requestTimeout, transport, requestHandlers, notificationHandlers);
}
/**
* Create a new McpAsyncClient with the given transport and session request-response
* timeout.
@ -129,7 +242,9 @@ public class McpAsyncClient {
* @param promptsChangeConsumers the prompts change consumers.
* @param loggingConsumers the logging consumers.
* @param samplingHandler the sampling handler.
* @deprecated Use {@link McpClient#async(ClientMcpTransport)} to obtain an instance.
*/
@Deprecated
public McpAsyncClient(ClientMcpTransport transport, Duration requestTimeout, Implementation clientInfo,
ClientCapabilities clientCapabilities, Map<String, Root> roots,
List<Consumer<List<McpSchema.Tool>>> toolsChangeConsumers,
@ -153,11 +268,11 @@ public class McpAsyncClient {
this.roots = roots != null ? new ConcurrentHashMap<>(roots) : new ConcurrentHashMap<>();
// Request Handlers
Map<String, RequestHandler> requestHanlers = new HashMap<>();
Map<String, RequestHandler<?>> requestHandlers = new HashMap<>();
// Roots List Request Handler
if (this.roots != null && this.clientCapabilities.roots() != null) {
requestHanlers.put(McpSchema.METHOD_ROOTS_LIST, rootsListRequestHandler());
if (this.clientCapabilities.roots() != null) {
requestHandlers.put(McpSchema.METHOD_ROOTS_LIST, rootsListRequestHandler());
}
// Sampling Handler
@ -165,8 +280,9 @@ public class McpAsyncClient {
if (samplingHandler == null) {
throw new McpError("Sampling handler must not be null when client capabilities include sampling");
}
this.samplingHandler = samplingHandler;
requestHanlers.put(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, samplingCreateMessageHandler());
this.samplingHandler = r -> Mono.fromCallable(() -> samplingHandler.apply(r))
.subscribeOn(Schedulers.boundedElastic());
requestHandlers.put(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, samplingCreateMessageHandler());
}
// Notification Handlers
@ -208,7 +324,7 @@ public class McpAsyncClient {
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_MESSAGE,
loggingNotificationHandler(loggingConsumersFinal));
this.mcpSession = new DefaultMcpSession(requestTimeout, transport, requestHanlers, notificationHandlers);
this.mcpSession = new DefaultMcpSession(requestTimeout, transport, requestHandlers, notificationHandlers);
}
@ -416,15 +532,13 @@ public class McpAsyncClient {
// --------------------------
// Sampling
// --------------------------
private RequestHandler samplingCreateMessageHandler() {
private RequestHandler<CreateMessageResult> samplingCreateMessageHandler() {
return params -> {
McpSchema.CreateMessageRequest request = transport.unmarshalFrom(params,
new TypeReference<McpSchema.CreateMessageRequest>() {
});
CreateMessageResult response = this.samplingHandler.apply(request);
return Mono.just(response);
return this.samplingHandler.apply(request);
};
}
@ -500,6 +614,26 @@ public class McpAsyncClient {
}).then(); // Convert to Mono<Void>
}
/**
* Creates a notification handler for tools/list_changed notifications from the
* server. When the server's available tools change, it sends a notification to inform
* connected clients. This handler automatically fetches the updated tool list and
* distributes it to all registered consumers.
* @param toolsChangeConsumers List of consumers that will be notified when the tools
* list changes. Each consumer receives the complete updated list of tools.
* @return A NotificationHandler that processes tools/list_changed notifications by:
* 1. Fetching the current list of tools from the server 2. Distributing the updated
* list to all registered consumers 3. Handling any errors that occur during this
* process
*/
private NotificationHandler asyncToolsChangeNotificationHandler(
List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumers) {
// TODO: params are not used yet
return params -> listTools().flatMap(listToolsResult -> Flux.fromIterable(toolsChangeConsumers)
.flatMap(consumer -> consumer.apply(listToolsResult.tools()))
.then());
}
// --------------------------
// Resources
// --------------------------
@ -621,6 +755,13 @@ public class McpAsyncClient {
}).then();
}
private NotificationHandler asyncResourcesChangeNotificationHandler(
List<Function<List<McpSchema.Resource>, Mono<Void>>> resourcesChangeConsumers) {
return params -> listResources().flatMap(listResourcesResult -> Flux.fromIterable(resourcesChangeConsumers)
.flatMap(consumer -> consumer.apply(listResourcesResult.resources()))
.then());
}
// --------------------------
// Prompts
// --------------------------
@ -682,6 +823,13 @@ public class McpAsyncClient {
}; // @formatter:on
}
private NotificationHandler asyncPromptsChangeNotificationHandler(
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers) {
return params -> listPrompts().flatMap(listPromptsResult -> Flux.fromIterable(promptsChangeConsumers)
.flatMap(consumer -> consumer.apply(listPromptsResult.prompts()))
.then());
}
// --------------------------
// Logging
// --------------------------
@ -711,6 +859,28 @@ public class McpAsyncClient {
};
}
/**
* Create a notification handler for logging notifications from the server. This
* handler automatically distributes logging messages to all registered consumers.
* @param loggingConsumers List of consumers that will be notified when a logging
* message is received. Each consumer receives the logging message notification.
* @return A NotificationHandler that processes log notifications by distributing the
* message to all registered consumers
*/
private NotificationHandler asyncLoggingNotificationHandler(
List<Function<LoggingMessageNotification, Mono<Void>>> loggingConsumers) {
return params -> {
McpSchema.LoggingMessageNotification loggingMessageNotification = transport.unmarshalFrom(params,
new TypeReference<McpSchema.LoggingMessageNotification>() {
});
return Flux.fromIterable(loggingConsumers)
.flatMap(consumer -> consumer.apply(loggingMessageNotification))
.then();
};
}
/**
* Client can set the minimum logging level it wants to receive from the server.
* @param loggingLevel the min logging level

View file

@ -24,6 +24,8 @@ import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Function;
import reactor.core.publisher.Mono;
import org.springframework.ai.mcp.spec.ClientMcpTransport;
import org.springframework.ai.mcp.spec.McpSchema;
import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities;
@ -58,24 +60,30 @@ import org.springframework.ai.mcp.util.Assert;
* </ul>
*
* <p>
* Example of creating a basic client: <pre>{@code
* McpClient.using(transport)
* Example of creating a basic synchronous client: <pre>{@code
* McpClient.sync(transport)
* .requestTimeout(Duration.ofSeconds(5))
* .sync(); // or .async()
* .build();
* }</pre>
*
* Example of creating a basic asynchronous client: <pre>{@code
* McpClient.async(transport)
* .requestTimeout(Duration.ofSeconds(5))
* .build();
* }</pre>
*
* <p>
* Example with advanced configuration: <pre>{@code
* McpClient.using(transport)
* Example with advanced asynchronous configuration: <pre>{@code
* McpClient.async(transport)
* .requestTimeout(Duration.ofSeconds(10))
* .capabilities(new ClientCapabilities(...))
* .clientInfo(new Implementation("My Client", "1.0.0"))
* .roots(new Root("file://workspace", "Workspace Files"))
* .toolsChangeConsumer(tools -> System.out.println("Tools updated: " + tools))
* .resourcesChangeConsumer(resources -> System.out.println("Resources updated: " + resources))
* .promptsChangeConsumer(prompts -> System.out.println("Prompts updated: " + prompts))
* .loggingConsumer(message -> System.out.println("Log message: " + message))
* .async();
* .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> System.out.println("Tools updated: " + tools)))
* .resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> System.out.println("Resources updated: " + resources)))
* .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> System.out.println("Prompts updated: " + prompts)))
* .loggingConsumer(message -> Mono.fromRunnable(() -> System.out.println("Log message: " + message)))
* .build();
* }</pre>
*
* <p>
@ -115,7 +123,11 @@ public interface McpClient {
* and {@code SseClientTransport} for SSE-based communication.
* @return A new builder instance for configuring the client
* @throws IllegalArgumentException if transport is null
* @deprecated Use {@link #sync(ClientMcpTransport)} or
* {@link #async(ClientMcpTransport)} specification builder to configure the client
* and build an instance.
*/
@Deprecated
public static Builder using(ClientMcpTransport transport) {
return new Builder(transport);
}
@ -136,7 +148,12 @@ public interface McpClient {
* <li>Change notification handlers for tools, resources, and prompts
* <li>Custom message sampling logic
* </ul>
*
* @deprecated Use {@link #sync(ClientMcpTransport)} or
* {@link #async(ClientMcpTransport)} specification builder to instantiate an
* instance.
*/
@Deprecated
public static class Builder {
private final ClientMcpTransport transport;
@ -353,4 +370,431 @@ public interface McpClient {
}
/**
* Synchronous client specification.
*/
class SyncSpec {
private final ClientMcpTransport transport;
private Duration requestTimeout = Duration.ofSeconds(20); // Default timeout
private ClientCapabilities capabilities;
private Implementation clientInfo = new Implementation("Spring AI MCP Client", "0.3.1");
private Map<String, Root> roots = new HashMap<>();
private List<Consumer<List<McpSchema.Tool>>> toolsChangeConsumers = new ArrayList<>();
private List<Consumer<List<McpSchema.Resource>>> resourcesChangeConsumers = new ArrayList<>();
private List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers = new ArrayList<>();
private List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers = new ArrayList<>();
private Function<CreateMessageRequest, CreateMessageResult> samplingHandler;
private SyncSpec(ClientMcpTransport transport) {
this.transport = transport;
}
/**
* Sets the duration to wait for server responses before timing out requests. This
* timeout applies to all requests made through the client, including tool calls,
* resource access, and prompt operations.
* @param requestTimeout The duration to wait before timing out requests. Must not
* be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if requestTimeout is null
*/
public SyncSpec requestTimeout(Duration requestTimeout) {
Assert.notNull(requestTimeout, "Request timeout must not be null");
this.requestTimeout = requestTimeout;
return this;
}
/**
* Sets the client capabilities that will be advertised to the server during
* connection initialization. Capabilities define what features the client
* supports, such as tool execution, resource access, and prompt handling.
* @param capabilities The client capabilities configuration. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if capabilities is null
*/
public SyncSpec capabilities(ClientCapabilities capabilities) {
Assert.notNull(capabilities, "Capabilities must not be null");
this.capabilities = capabilities;
return this;
}
/**
* Sets the client implementation information that will be shared with the server
* during connection initialization. This helps with version compatibility and
* debugging.
* @param clientInfo The client implementation details including name and version.
* Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if clientInfo is null
*/
public SyncSpec clientInfo(Implementation clientInfo) {
Assert.notNull(clientInfo, "Client info must not be null");
this.clientInfo = clientInfo;
return this;
}
/**
* Sets the root URIs that this client can access. Roots define the base URIs for
* resources that the client can request from the server. For example, a root
* might be "file://workspace" for accessing workspace files.
* @param roots A list of root definitions. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if roots is null
*/
public SyncSpec roots(List<Root> roots) {
Assert.notNull(roots, "Roots must not be null");
for (Root root : roots) {
this.roots.put(root.uri(), root);
}
return this;
}
/**
* Sets the root URIs that this client can access, using a varargs parameter for
* convenience. This is an alternative to {@link #roots(List)}.
* @param roots An array of root definitions. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if roots is null
* @see #roots(List)
*/
public SyncSpec roots(Root... roots) {
Assert.notNull(roots, "Roots must not be null");
for (Root root : roots) {
this.roots.put(root.uri(), root);
}
return this;
}
/**
* Sets a custom sampling handler for processing message creation requests. The
* sampling handler can modify or validate messages before they are sent to the
* server, enabling custom processing logic.
* @param samplingHandler A function that processes message requests and returns
* results. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if samplingHandler is null
*/
public SyncSpec sampling(Function<CreateMessageRequest, CreateMessageResult> samplingHandler) {
Assert.notNull(samplingHandler, "Sampling handler must not be null");
this.samplingHandler = samplingHandler;
return this;
}
/**
* Adds a consumer to be notified when the available tools change. This allows the
* client to react to changes in the server's tool capabilities, such as tools
* being added or removed.
* @param toolsChangeConsumer A consumer that receives the updated list of
* available tools. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if toolsChangeConsumer is null
*/
public SyncSpec toolsChangeConsumer(Consumer<List<McpSchema.Tool>> toolsChangeConsumer) {
Assert.notNull(toolsChangeConsumer, "Tools change consumer must not be null");
this.toolsChangeConsumers.add(toolsChangeConsumer);
return this;
}
/**
* Adds a consumer to be notified when the available resources change. This allows
* the client to react to changes in the server's resource availability, such as
* files being added or removed.
* @param resourcesChangeConsumer A consumer that receives the updated list of
* available resources. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if resourcesChangeConsumer is null
*/
public SyncSpec resourcesChangeConsumer(Consumer<List<McpSchema.Resource>> resourcesChangeConsumer) {
Assert.notNull(resourcesChangeConsumer, "Resources change consumer must not be null");
this.resourcesChangeConsumers.add(resourcesChangeConsumer);
return this;
}
/**
* Adds a consumer to be notified when the available prompts change. This allows
* the client to react to changes in the server's prompt templates, such as new
* templates being added or existing ones being modified.
* @param promptsChangeConsumer A consumer that receives the updated list of
* available prompts. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if promptsChangeConsumer is null
*/
public SyncSpec promptsChangeConsumer(Consumer<List<McpSchema.Prompt>> promptsChangeConsumer) {
Assert.notNull(promptsChangeConsumer, "Prompts change consumer must not be null");
this.promptsChangeConsumers.add(promptsChangeConsumer);
return this;
}
/**
* Adds a consumer to be notified when logging messages are received from the
* server. This allows the client to react to log messages, such as warnings or
* errors, that are sent by the server.
* @param loggingConsumer A consumer that receives logging messages. Must not be
* null.
* @return This builder instance for method chaining
*/
public SyncSpec loggingConsumer(Consumer<McpSchema.LoggingMessageNotification> loggingConsumer) {
Assert.notNull(loggingConsumer, "Logging consumer must not be null");
this.loggingConsumers.add(loggingConsumer);
return this;
}
/**
* Adds multiple consumers to be notified when logging messages are received from
* the server. This allows the client to react to log messages, such as warnings
* or errors, that are sent by the server.
* @param loggingConsumers A list of consumers that receive logging messages. Must
* not be null.
* @return This builder instance for method chaining
*/
public SyncSpec loggingConsumers(List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers) {
Assert.notNull(loggingConsumers, "Logging consumers must not be null");
this.loggingConsumers.addAll(loggingConsumers);
return this;
}
/**
* Create an instance of {@link McpSyncClient} with the provided configurations or
* sensible defaults.
* @return a new instance of {@link McpSyncClient}.
*/
public McpSyncClient build() {
McpClientFeatures.Sync syncFeatures = new McpClientFeatures.Sync(this.clientInfo, this.capabilities,
this.roots, this.toolsChangeConsumers, this.resourcesChangeConsumers, this.promptsChangeConsumers,
this.loggingConsumers, this.samplingHandler);
McpClientFeatures.Async asyncFeatures = McpClientFeatures.Async.fromSync(syncFeatures);
return new McpSyncClient(new McpAsyncClient(transport, this.requestTimeout, asyncFeatures));
}
}
/**
* Asynchronous client specification.
*/
class AsyncSpec {
private final ClientMcpTransport transport;
private Duration requestTimeout = Duration.ofSeconds(20); // Default timeout
private ClientCapabilities capabilities;
private Implementation clientInfo = new Implementation("Spring AI MCP Client", "0.3.1");
private Map<String, Root> roots = new HashMap<>();
private List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumers = new ArrayList<>();
private List<Function<List<McpSchema.Resource>, Mono<Void>>> resourcesChangeConsumers = new ArrayList<>();
private List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers = new ArrayList<>();
private List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers = new ArrayList<>();
private Function<CreateMessageRequest, Mono<CreateMessageResult>> samplingHandler;
private AsyncSpec(ClientMcpTransport transport) {
this.transport = transport;
}
/**
* Sets the duration to wait for server responses before timing out requests. This
* timeout applies to all requests made through the client, including tool calls,
* resource access, and prompt operations.
* @param requestTimeout The duration to wait before timing out requests. Must not
* be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if requestTimeout is null
*/
public AsyncSpec requestTimeout(Duration requestTimeout) {
Assert.notNull(requestTimeout, "Request timeout must not be null");
this.requestTimeout = requestTimeout;
return this;
}
/**
* Sets the client capabilities that will be advertised to the server during
* connection initialization. Capabilities define what features the client
* supports, such as tool execution, resource access, and prompt handling.
* @param capabilities The client capabilities configuration. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if capabilities is null
*/
public AsyncSpec capabilities(ClientCapabilities capabilities) {
Assert.notNull(capabilities, "Capabilities must not be null");
this.capabilities = capabilities;
return this;
}
/**
* Sets the client implementation information that will be shared with the server
* during connection initialization. This helps with version compatibility and
* debugging.
* @param clientInfo The client implementation details including name and version.
* Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if clientInfo is null
*/
public AsyncSpec clientInfo(Implementation clientInfo) {
Assert.notNull(clientInfo, "Client info must not be null");
this.clientInfo = clientInfo;
return this;
}
/**
* Sets the root URIs that this client can access. Roots define the base URIs for
* resources that the client can request from the server. For example, a root
* might be "file://workspace" for accessing workspace files.
* @param roots A list of root definitions. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if roots is null
*/
public AsyncSpec roots(List<Root> roots) {
Assert.notNull(roots, "Roots must not be null");
for (Root root : roots) {
this.roots.put(root.uri(), root);
}
return this;
}
/**
* Sets the root URIs that this client can access, using a varargs parameter for
* convenience. This is an alternative to {@link #roots(List)}.
* @param roots An array of root definitions. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if roots is null
* @see #roots(List)
*/
public AsyncSpec roots(Root... roots) {
Assert.notNull(roots, "Roots must not be null");
for (Root root : roots) {
this.roots.put(root.uri(), root);
}
return this;
}
/**
* Sets a custom sampling handler for processing message creation requests. The
* sampling handler can modify or validate messages before they are sent to the
* server, enabling custom processing logic.
* @param samplingHandler A function that processes message requests and returns
* results. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if samplingHandler is null
*/
public AsyncSpec sampling(Function<CreateMessageRequest, Mono<CreateMessageResult>> samplingHandler) {
Assert.notNull(samplingHandler, "Sampling handler must not be null");
this.samplingHandler = samplingHandler;
return this;
}
/**
* Adds a consumer to be notified when the available tools change. This allows the
* client to react to changes in the server's tool capabilities, such as tools
* being added or removed.
* @param toolsChangeConsumer A consumer that receives the updated list of
* available tools. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if toolsChangeConsumer is null
*/
public AsyncSpec toolsChangeConsumer(Function<List<McpSchema.Tool>, Mono<Void>> toolsChangeConsumer) {
Assert.notNull(toolsChangeConsumer, "Tools change consumer must not be null");
this.toolsChangeConsumers.add(toolsChangeConsumer);
return this;
}
/**
* Adds a consumer to be notified when the available resources change. This allows
* the client to react to changes in the server's resource availability, such as
* files being added or removed.
* @param resourcesChangeConsumer A consumer that receives the updated list of
* available resources. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if resourcesChangeConsumer is null
*/
public AsyncSpec resourcesChangeConsumer(
Function<List<McpSchema.Resource>, Mono<Void>> resourcesChangeConsumer) {
Assert.notNull(resourcesChangeConsumer, "Resources change consumer must not be null");
this.resourcesChangeConsumers.add(resourcesChangeConsumer);
return this;
}
/**
* Adds a consumer to be notified when the available prompts change. This allows
* the client to react to changes in the server's prompt templates, such as new
* templates being added or existing ones being modified.
* @param promptsChangeConsumer A consumer that receives the updated list of
* available prompts. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if promptsChangeConsumer is null
*/
public AsyncSpec promptsChangeConsumer(Function<List<McpSchema.Prompt>, Mono<Void>> promptsChangeConsumer) {
Assert.notNull(promptsChangeConsumer, "Prompts change consumer must not be null");
this.promptsChangeConsumers.add(promptsChangeConsumer);
return this;
}
/**
* Adds a consumer to be notified when logging messages are received from the
* server. This allows the client to react to log messages, such as warnings or
* errors, that are sent by the server.
* @param loggingConsumer A consumer that receives logging messages. Must not be
* null.
* @return This builder instance for method chaining
*/
public AsyncSpec loggingConsumer(Function<McpSchema.LoggingMessageNotification, Mono<Void>> loggingConsumer) {
Assert.notNull(loggingConsumer, "Logging consumer must not be null");
this.loggingConsumers.add(loggingConsumer);
return this;
}
/**
* Adds multiple consumers to be notified when logging messages are received from
* the server. This allows the client to react to log messages, such as warnings
* or errors, that are sent by the server.
* @param loggingConsumers A list of consumers that receive logging messages. Must
* not be null.
* @return This builder instance for method chaining
*/
public AsyncSpec loggingConsumers(
List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers) {
Assert.notNull(loggingConsumers, "Logging consumers must not be null");
this.loggingConsumers.addAll(loggingConsumers);
return this;
}
/**
* Create an instance of {@link McpAsyncClient} with the provided configurations
* or sensible defaults.
* @return a new instance of {@link McpAsyncClient}.
*/
public McpAsyncClient build() {
return new McpAsyncClient(this.transport, this.requestTimeout,
new McpClientFeatures.Async(this.clientInfo, this.capabilities, this.roots,
this.toolsChangeConsumers, this.resourcesChangeConsumers, this.promptsChangeConsumers,
this.loggingConsumers, this.samplingHandler));
}
}
static SyncSpec sync(ClientMcpTransport transport) {
return new SyncSpec(transport);
}
static AsyncSpec async(ClientMcpTransport transport) {
return new AsyncSpec(transport);
}
}

View file

@ -0,0 +1,200 @@
package org.springframework.ai.mcp.client;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.Function;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import org.springframework.ai.mcp.spec.McpSchema;
import org.springframework.ai.mcp.util.Assert;
import org.springframework.ai.mcp.util.Utils;
/**
* Internal representation of features and capabilities for Model Context Protocol (MCP)
* clients. This class provides two record types for managing client features:
* <ul>
* <li>{@link Async} for non-blocking operations with Project Reactor's Mono responses
* <li>{@link Sync} for blocking operations with direct responses
* </ul>
*
* <p>
* Each feature specification includes:
* <ul>
* <li>Client implementation information and capabilities
* <li>Root URI mappings for resource access
* <li>Change notification handlers for tools, resources, and prompts
* <li>Logging message consumers
* <li>Message sampling handlers for request processing
* </ul>
*
* <p>
* The class supports conversion between synchronous and asynchronous specifications
* through the {@link Async#fromSync} method, which ensures proper handling of blocking
* operations in non-blocking contexts by scheduling them on a bounded elastic scheduler.
*
* @see McpClient
* @see McpSchema.Implementation
* @see McpSchema.ClientCapabilities
*/
class McpClientFeatures {
/**
* Asynchronous client features specification providing the capabilities and request
* and notification handlers.
*
* @param clientInfo the client implementation information.
* @param clientCapabilities the client capabilities.
* @param roots the roots.
* @param toolsChangeConsumers the tools change consumers.
* @param resourcesChangeConsumers the resources change consumers.
* @param promptsChangeConsumers the prompts change consumers.
* @param loggingConsumers the logging consumers.
* @param samplingHandler the sampling handler.
*/
record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities,
Map<String, McpSchema.Root> roots, List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumers,
List<Function<List<McpSchema.Resource>, Mono<Void>>> resourcesChangeConsumers,
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers,
List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers,
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler) {
/**
* Create an instance and validate the arguments.
* @param clientInfo the client implementation information.
* @param clientCapabilities the client capabilities.
* @param roots the roots.
* @param toolsChangeConsumers the tools change consumers.
* @param resourcesChangeConsumers the resources change consumers.
* @param promptsChangeConsumers the prompts change consumers.
* @param loggingConsumers the logging consumers.
* @param samplingHandler the sampling handler.
*/
public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities,
Map<String, McpSchema.Root> roots,
List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumers,
List<Function<List<McpSchema.Resource>, Mono<Void>>> resourcesChangeConsumers,
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers,
List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers,
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler) {
Assert.notNull(clientInfo, "Client info must not be null");
this.clientInfo = clientInfo;
this.clientCapabilities = (clientCapabilities != null) ? clientCapabilities
: new McpSchema.ClientCapabilities(null,
!Utils.isEmpty(roots) ? new McpSchema.ClientCapabilities.RootCapabilities(false) : null,
samplingHandler != null ? new McpSchema.ClientCapabilities.Sampling() : null);
this.roots = roots != null ? new ConcurrentHashMap<>(roots) : new ConcurrentHashMap<>();
this.toolsChangeConsumers = toolsChangeConsumers;
this.resourcesChangeConsumers = resourcesChangeConsumers;
this.promptsChangeConsumers = promptsChangeConsumers;
this.loggingConsumers = loggingConsumers;
this.samplingHandler = samplingHandler;
}
/**
* Convert a synchronous specification into an asynchronous one and provide
* blocking code offloading to prevent accidental blocking of the non-blocking
* transport.
* @param syncSpec a potentially blocking, synchronous specification.
* @return a specification which is protected from blocking calls specified by the
* user.
*/
public static Async fromSync(Sync syncSpec) {
List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumers = new ArrayList<>();
for (Consumer<List<McpSchema.Tool>> consumer : syncSpec.toolsChangeConsumers()) {
toolsChangeConsumers.add(t -> Mono.<Void>fromRunnable(() -> consumer.accept(t))
.subscribeOn(Schedulers.boundedElastic()));
}
List<Function<List<McpSchema.Resource>, Mono<Void>>> resourcesChangeConsumers = new ArrayList<>();
for (Consumer<List<McpSchema.Resource>> consumer : syncSpec.resourcesChangeConsumers()) {
resourcesChangeConsumers.add(r -> Mono.<Void>fromRunnable(() -> consumer.accept(r))
.subscribeOn(Schedulers.boundedElastic()));
}
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers = new ArrayList<>();
for (Consumer<List<McpSchema.Prompt>> consumer : syncSpec.promptsChangeConsumers()) {
promptsChangeConsumers.add(p -> Mono.<Void>fromRunnable(() -> consumer.accept(p))
.subscribeOn(Schedulers.boundedElastic()));
}
List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers = new ArrayList<>();
for (Consumer<McpSchema.LoggingMessageNotification> consumer : syncSpec.loggingConsumers()) {
loggingConsumers.add(l -> Mono.<Void>fromRunnable(() -> consumer.accept(l))
.subscribeOn(Schedulers.boundedElastic()));
}
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler = r -> Mono
.fromCallable(() -> syncSpec.samplingHandler().apply(r))
.subscribeOn(Schedulers.boundedElastic());
return new Async(syncSpec.clientInfo(), syncSpec.clientCapabilities(), syncSpec.roots(),
toolsChangeConsumers, resourcesChangeConsumers, promptsChangeConsumers, loggingConsumers,
samplingHandler);
}
}
/**
* Synchronous client features specification providing the capabilities and request
* and notification handlers.
*
* @param clientInfo the client implementation information.
* @param clientCapabilities the client capabilities.
* @param roots the roots.
* @param toolsChangeConsumers the tools change consumers.
* @param resourcesChangeConsumers the resources change consumers.
* @param promptsChangeConsumers the prompts change consumers.
* @param loggingConsumers the logging consumers.
* @param samplingHandler the sampling handler.
*/
public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities,
Map<String, McpSchema.Root> roots, List<Consumer<List<McpSchema.Tool>>> toolsChangeConsumers,
List<Consumer<List<McpSchema.Resource>>> resourcesChangeConsumers,
List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers,
List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers,
Function<McpSchema.CreateMessageRequest, McpSchema.CreateMessageResult> samplingHandler) {
/**
* Create an instance and validate the arguments.
* @param clientInfo the client implementation information.
* @param clientCapabilities the client capabilities.
* @param roots the roots.
* @param toolsChangeConsumers the tools change consumers.
* @param resourcesChangeConsumers the resources change consumers.
* @param promptsChangeConsumers the prompts change consumers.
* @param loggingConsumers the logging consumers.
* @param samplingHandler the sampling handler.
*/
public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities,
Map<String, McpSchema.Root> roots, List<Consumer<List<McpSchema.Tool>>> toolsChangeConsumers,
List<Consumer<List<McpSchema.Resource>>> resourcesChangeConsumers,
List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers,
List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers,
Function<McpSchema.CreateMessageRequest, McpSchema.CreateMessageResult> samplingHandler) {
Assert.notNull(clientInfo, "Client info must not be null");
this.clientInfo = clientInfo;
this.clientCapabilities = (clientCapabilities != null) ? clientCapabilities
: new McpSchema.ClientCapabilities(null,
!Utils.isEmpty(roots) ? new McpSchema.ClientCapabilities.RootCapabilities(false) : null,
samplingHandler != null ? new McpSchema.ClientCapabilities.Sampling() : null);
this.roots = roots != null ? new HashMap<>(roots) : new HashMap<>();
this.toolsChangeConsumers = toolsChangeConsumers;
this.resourcesChangeConsumers = resourcesChangeConsumers;
this.promptsChangeConsumers = promptsChangeConsumers;
this.loggingConsumers = loggingConsumers;
this.samplingHandler = samplingHandler;
}
}
}

View file

@ -29,10 +29,39 @@ import org.springframework.ai.mcp.spec.McpSchema.ListPromptsResult;
import org.springframework.ai.mcp.util.Assert;
/**
* A synchronous client for the Model Context Protocol (MCP).
* A synchronous client implementation for the Model Context Protocol (MCP) that wraps an
* {@link McpAsyncClient} to provide blocking operations.
*
* <p>
* This client implements the MCP specification by delegating to an asynchronous client
* and blocking on the results. Key features include:
* <ul>
* <li>Synchronous, blocking API for simpler integration in non-reactive applications
* <li>Tool discovery and invocation for server-provided functionality
* <li>Resource access and management with URI-based addressing
* <li>Prompt template handling for standardized AI interactions
* <li>Real-time notifications for tools, resources, and prompts changes
* <li>Structured logging with configurable severity levels
* </ul>
*
* <p>
* The client follows the same lifecycle as its async counterpart:
* <ol>
* <li>Initialization - Establishes connection and negotiates capabilities
* <li>Normal Operation - Handles requests and notifications
* <li>Graceful Shutdown - Ensures clean connection termination
* </ol>
*
* <p>
* This implementation implements {@link AutoCloseable} for resource cleanup and provides
* both immediate and graceful shutdown options. All operations block until completion or
* timeout, making it suitable for traditional synchronous programming models.
*
* @author Dariusz Jędrzejczyk
* @author Christian Tzolov
* @see McpClient
* @see McpAsyncClient
* @see McpSchema
*/
public class McpSyncClient implements AutoCloseable {

View file

@ -24,10 +24,12 @@ import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
import java.util.function.Function;
import com.fasterxml.jackson.core.type.TypeReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
@ -48,9 +50,43 @@ import org.springframework.ai.mcp.util.Utils;
/**
* The Model Context Protocol (MCP) server implementation that provides asynchronous
* communication.
* communication using Project Reactor's Mono and Flux types.
*
* <p>
* This server implements the MCP specification, enabling AI models to expose tools,
* resources, and prompts through a standardized interface. Key features include:
* <ul>
* <li>Asynchronous communication using reactive programming patterns
* <li>Dynamic tool registration and management
* <li>Resource handling with URI-based addressing
* <li>Prompt template management
* <li>Real-time client notifications for state changes
* <li>Structured logging with configurable severity levels
* <li>Support for client-side AI model sampling
* </ul>
*
* <p>
* The server follows a lifecycle:
* <ol>
* <li>Initialization - Accepts client connections and negotiates capabilities
* <li>Normal Operation - Handles client requests and sends notifications
* <li>Graceful Shutdown - Ensures clean connection termination
* </ol>
*
* <p>
* This implementation uses Project Reactor for non-blocking operations, making it
* suitable for high-throughput scenarios and reactive applications. All operations return
* Mono or Flux types that can be composed into reactive pipelines.
*
* <p>
* The server supports runtime modification of its capabilities through methods like
* {@link #addTool}, {@link #addResource}, and {@link #addPrompt}, automatically notifying
* connected clients of changes when configured to do so.
*
* @author Christian Tzolov
* @see McpServer
* @see McpSchema
* @see DefaultMcpSession
*/
public class McpAsyncServer {
@ -75,16 +111,84 @@ public class McpAsyncServer {
/**
* Thread-safe list of tool handlers that can be modified at runtime.
*/
private final CopyOnWriteArrayList<ToolRegistration> tools;
private final CopyOnWriteArrayList<McpServerFeatures.AsyncToolRegistration> tools = new CopyOnWriteArrayList<>();
private final CopyOnWriteArrayList<McpSchema.ResourceTemplate> resourceTemplates;
private final CopyOnWriteArrayList<McpSchema.ResourceTemplate> resourceTemplates = new CopyOnWriteArrayList<>();
private final ConcurrentHashMap<String, ResourceRegistration> resources;
private final ConcurrentHashMap<String, McpServerFeatures.AsyncResourceRegistration> resources = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, PromptRegistration> prompts;
private final ConcurrentHashMap<String, McpServerFeatures.AsyncPromptRegistration> prompts = new ConcurrentHashMap<>();
private LoggingLevel minLoggingLevel = LoggingLevel.DEBUG;
/**
* Create a new McpAsyncServer with the given transport and capabilities.
* @param mcpTransport The transport layer implementation for MCP communication.
* @param features The MCP server supported features.
*/
McpAsyncServer(ServerMcpTransport mcpTransport, McpServerFeatures.Async features) {
this.serverInfo = features.serverInfo();
this.serverCapabilities = features.serverCapabilities();
this.tools.addAll(features.tools());
this.resources.putAll(features.resources());
this.resourceTemplates.addAll(features.resourceTemplates());
this.prompts.putAll(features.prompts());
Map<String, DefaultMcpSession.RequestHandler<?>> requestHandlers = new HashMap<>();
// Initialize request handlers for standard MCP methods
requestHandlers.put(McpSchema.METHOD_INITIALIZE, initializeRequestHandler());
// Ping MUST respond with an empty data, but not NULL response.
requestHandlers.put(McpSchema.METHOD_PING, (params) -> Mono.just(""));
// Add tools API handlers if the tool capability is enabled
if (this.serverCapabilities.tools() != null) {
requestHandlers.put(McpSchema.METHOD_TOOLS_LIST, toolsListRequestHandler());
requestHandlers.put(McpSchema.METHOD_TOOLS_CALL, toolsCallRequestHandler());
}
// Add resources API handlers if provided
if (!Utils.isEmpty(this.resources)) {
requestHandlers.put(McpSchema.METHOD_RESOURCES_LIST, resourcesListRequestHandler());
requestHandlers.put(McpSchema.METHOD_RESOURCES_READ, resourcesReadRequestHandler());
}
// Add resource templates API handlers if provided.
if (!Utils.isEmpty(this.resourceTemplates)) {
requestHandlers.put(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, resourceTemplateListRequestHandler());
}
// Add prompts API handlers if provider exists
if (!Utils.isEmpty(this.prompts)) {
requestHandlers.put(McpSchema.METHOD_PROMPT_LIST, promptsListRequestHandler());
requestHandlers.put(McpSchema.METHOD_PROMPT_GET, promptsGetRequestHandler());
}
// Add logging API handlers if the logging capability is enabled
if (this.serverCapabilities.logging() != null) {
requestHandlers.put(McpSchema.METHOD_LOGGING_SET_LEVEL, setLoggerRequestHandler());
}
Map<String, NotificationHandler> notificationHandlers = new HashMap<>();
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_INITIALIZED, (params) -> Mono.empty());
List<Function<List<McpSchema.Root>, Mono<Void>>> rootsChangeConsumers = features.rootsChangeConsumers();
if (Utils.isEmpty(rootsChangeConsumers)) {
rootsChangeConsumers = List.of((roots) -> Mono.fromRunnable(() -> logger
.warn("Roots list changed notification, but no consumers provided. Roots list changed: {}", roots)));
}
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED,
asyncRootsListChangedNotificationHandler(rootsChangeConsumers));
this.transport = mcpTransport;
this.mcpSession = new DefaultMcpSession(Duration.ofSeconds(10), mcpTransport, requestHandlers,
notificationHandlers);
}
/**
* Create a new McpAsyncServer with the given transport and capabilities.
* @param mcpTransport The transport layer implementation for MCP communication
@ -96,18 +200,28 @@ public class McpAsyncServer {
* @param prompts The map of prompt registrations
* @param rootsChangeConsumers The list of consumers that will be notified when the
* roots list changes
* @deprecated Use {@link McpServer#sync(ServerMcpTransport)} or
* {@link McpServer#async(ServerMcpTransport)} to create a new server instance.
*/
@Deprecated
public McpAsyncServer(ServerMcpTransport mcpTransport, McpSchema.Implementation serverInfo,
McpSchema.ServerCapabilities serverCapabilities, List<ToolRegistration> tools,
Map<String, ResourceRegistration> resources, List<McpSchema.ResourceTemplate> resourceTemplates,
Map<String, PromptRegistration> prompts, List<Consumer<List<McpSchema.Root>>> rootsChangeConsumers) {
this.serverInfo = serverInfo;
this.tools = new CopyOnWriteArrayList<>(tools != null ? tools : List.of());
this.resources = !Utils.isEmpty(resources) ? new ConcurrentHashMap<>(resources) : new ConcurrentHashMap<>();
this.resourceTemplates = !Utils.isEmpty(resourceTemplates) ? new CopyOnWriteArrayList<>(resourceTemplates)
: new CopyOnWriteArrayList<>();
this.prompts = !Utils.isEmpty(prompts) ? new ConcurrentHashMap<>(prompts) : new ConcurrentHashMap<>();
if (!Utils.isEmpty(tools)) {
this.tools.addAll(McpServer.mapDeprecatedTools(tools));
}
if (!Utils.isEmpty(resources)) {
this.resources.putAll(McpServer.mapDeprecatedResources(resources));
}
if (!Utils.isEmpty(resourceTemplates)) {
this.resourceTemplates.addAll(resourceTemplates);
}
if (!Utils.isEmpty(prompts)) {
this.prompts.putAll(McpServer.mapDeprecatedPrompts(prompts));
}
this.serverCapabilities = (serverCapabilities != null) ? serverCapabilities : new McpSchema.ServerCapabilities(
null, // experimental
@ -118,13 +232,13 @@ public class McpAsyncServer {
: null,
!Utils.isEmpty(this.tools) ? new McpSchema.ServerCapabilities.ToolCapabilities(false) : null);
Map<String, DefaultMcpSession.RequestHandler> requestHandlers = new HashMap<>();
Map<String, DefaultMcpSession.RequestHandler<?>> requestHandlers = new HashMap<>();
// Initialize request handlers for standard MCP methods
requestHandlers.put(McpSchema.METHOD_INITIALIZE, initializeRequestHandler());
// Ping MUST respond with an empty data, but not NULL response.
requestHandlers.put(McpSchema.METHOD_PING, (params) -> Mono.<Object>just(""));
requestHandlers.put(McpSchema.METHOD_PING, (params) -> Mono.just(""));
// Add tools API handlers if the tool capability is enabled
if (this.serverCapabilities.tools() != null) {
@ -173,7 +287,7 @@ public class McpAsyncServer {
// ---------------------------------------
// Lifecycle Management
// ---------------------------------------
private DefaultMcpSession.RequestHandler initializeRequestHandler() {
private DefaultMcpSession.RequestHandler<McpSchema.InitializeResult> initializeRequestHandler() {
return params -> {
McpSchema.InitializeRequest initializeRequest = transport.unmarshalFrom(params,
new TypeReference<McpSchema.InitializeRequest>() {
@ -187,14 +301,13 @@ public class McpAsyncServer {
initializeRequest.clientInfo());
if (!McpSchema.LATEST_PROTOCOL_VERSION.equals(initializeRequest.protocolVersion())) {
return Mono
.<Object>error(new McpError(
"Unsupported protocol version from client: " + initializeRequest.protocolVersion()))
return Mono.<McpSchema.InitializeResult>error(new McpError(
"Unsupported protocol version from client: " + initializeRequest.protocolVersion()))
.publishOn(Schedulers.boundedElastic());
}
return Mono
.<Object>just(new McpSchema.InitializeResult(McpSchema.LATEST_PROTOCOL_VERSION, this.serverCapabilities,
.just(new McpSchema.InitializeResult(McpSchema.LATEST_PROTOCOL_VERSION, this.serverCapabilities,
this.serverInfo, null))
.publishOn(Schedulers.boundedElastic());
};
@ -268,6 +381,7 @@ public class McpAsyncServer {
LIST_ROOTS_RESULT_TYPE_REF);
}
@Deprecated
private NotificationHandler rootsListChnagedNotificationHandler(
List<Consumer<List<McpSchema.Root>>> rootsChangeConsumers) {
@ -281,6 +395,13 @@ public class McpAsyncServer {
};
}
private NotificationHandler asyncRootsListChangedNotificationHandler(
List<Function<List<McpSchema.Root>, Mono<Void>>> rootsChangeConsumers) {
return params -> listRoots().flatMap(listRootsResult -> Flux.fromIterable(rootsChangeConsumers)
.flatMap(consumer -> consumer.apply(listRootsResult.roots()))
.then());
}
// ---------------------------------------
// Tool Management
// ---------------------------------------
@ -290,6 +411,44 @@ public class McpAsyncServer {
* @param toolRegistration The tool registration to add
* @return Mono that completes when clients have been notified of the change
*/
public Mono<Void> addTool(McpServerFeatures.AsyncToolRegistration toolRegistration) {
if (toolRegistration == null) {
return Mono.error(new McpError("Tool registration must not be null"));
}
if (toolRegistration.tool() == null) {
return Mono.error(new McpError("Tool must not be null"));
}
if (toolRegistration.call() == null) {
return Mono.error(new McpError("Tool call handler must not be null"));
}
if (this.serverCapabilities.tools() == null) {
return Mono.error(new McpError("Server must be configured with tool capabilities"));
}
return Mono.defer(() -> {
// Check for duplicate tool names
if (this.tools.stream().anyMatch(th -> th.tool().name().equals(toolRegistration.tool().name()))) {
return Mono
.error(new McpError("Tool with name '" + toolRegistration.tool().name() + "' already exists"));
}
this.tools.add(toolRegistration);
logger.info("Added tool handler: {}", toolRegistration.tool().name());
if (this.serverCapabilities.tools().listChanged()) {
return notifyToolsListChanged();
}
return Mono.empty();
});
}
/**
* Add a new tool registration at runtime.
* @param toolRegistration The tool registration to add
* @return Mono that completes when clients have been notified of the change
* @deprecated Use {@link #addTool(McpServerFeatures.AsyncToolRegistration)}.
*/
@Deprecated
public Mono<Void> addTool(ToolRegistration toolRegistration) {
if (toolRegistration == null) {
return Mono.error(new McpError("Tool registration must not be null"));
@ -309,7 +468,7 @@ public class McpAsyncServer {
return Mono.error(new McpError("Tool with name '" + toolRegistration.tool().name() + "' already exists"));
}
this.tools.add(toolRegistration);
this.tools.add(McpServer.mapDeprecatedTool(toolRegistration));
logger.info("Added tool handler: {}", toolRegistration.tool().name());
if (this.serverCapabilities.tools().listChanged()) {
return notifyToolsListChanged();
@ -330,15 +489,17 @@ public class McpAsyncServer {
return Mono.error(new McpError("Server must be configured with tool capabilities"));
}
boolean removed = this.tools.removeIf(toolRegistration -> toolRegistration.tool().name().equals(toolName));
if (removed) {
logger.info("Removed tool handler: {}", toolName);
if (this.serverCapabilities.tools().listChanged()) {
return notifyToolsListChanged();
return Mono.defer(() -> {
boolean removed = this.tools.removeIf(toolRegistration -> toolRegistration.tool().name().equals(toolName));
if (removed) {
logger.info("Removed tool handler: {}", toolName);
if (this.serverCapabilities.tools().listChanged()) {
return notifyToolsListChanged();
}
return Mono.empty();
}
return Mono.empty();
}
return Mono.error(new McpError("Tool with name '" + toolName + "' not found"));
return Mono.error(new McpError("Tool with name '" + toolName + "' not found"));
});
}
/**
@ -349,32 +510,30 @@ public class McpAsyncServer {
return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED, null);
}
private DefaultMcpSession.RequestHandler toolsListRequestHandler() {
private DefaultMcpSession.RequestHandler<McpSchema.ListToolsResult> toolsListRequestHandler() {
return params -> {
List<Tool> tools = this.tools.stream().map(ToolRegistration::tool).toList();
List<Tool> tools = this.tools.stream().map(McpServerFeatures.AsyncToolRegistration::tool).toList();
return Mono.just(new McpSchema.ListToolsResult(tools, null));
};
}
private DefaultMcpSession.RequestHandler toolsCallRequestHandler() {
private DefaultMcpSession.RequestHandler<CallToolResult> toolsCallRequestHandler() {
return params -> {
McpSchema.CallToolRequest callToolRequest = transport.unmarshalFrom(params,
new TypeReference<McpSchema.CallToolRequest>() {
});
Optional<ToolRegistration> toolRegistration = this.tools.stream()
Optional<McpServerFeatures.AsyncToolRegistration> toolRegistration = this.tools.stream()
.filter(tr -> callToolRequest.name().equals(tr.tool().name()))
.findAny();
if (toolRegistration.isEmpty()) {
return Mono.<Object>error(new McpError("Tool not found: " + callToolRequest.name()));
return Mono.error(new McpError("Tool not found: " + callToolRequest.name()));
}
return Mono.fromCallable(() -> toolRegistration.get().call().apply(callToolRequest.arguments()))
.map(result -> (Object) result)
.subscribeOn(Schedulers.boundedElastic());
return toolRegistration.map(tool -> tool.call().apply(callToolRequest.arguments()))
.orElse(Mono.error(new McpError("Tool not found: " + callToolRequest.name())));
};
}
@ -387,6 +546,35 @@ public class McpAsyncServer {
* @param resourceHandler The resource handler to add
* @return Mono that completes when clients have been notified of the change
*/
public Mono<Void> addResource(McpServerFeatures.AsyncResourceRegistration resourceHandler) {
if (resourceHandler == null || resourceHandler.resource() == null) {
return Mono.error(new McpError("Resource must not be null"));
}
if (this.serverCapabilities.resources() == null) {
return Mono.error(new McpError("Server must be configured with resource capabilities"));
}
return Mono.defer(() -> {
if (this.resources.putIfAbsent(resourceHandler.resource().uri(), resourceHandler) != null) {
return Mono
.error(new McpError("Resource with URI '" + resourceHandler.resource().uri() + "' already exists"));
}
logger.info("Added resource handler: {}", resourceHandler.resource().uri());
if (this.serverCapabilities.resources().listChanged()) {
return notifyResourcesListChanged();
}
return Mono.empty();
});
}
/**
* Add a new resource handler at runtime.
* @param resourceHandler The resource handler to add
* @return Mono that completes when clients have been notified of the change
* @deprecated Use {@link #addResource(McpServerFeatures.AsyncResourceRegistration)}.
*/
@Deprecated
public Mono<Void> addResource(ResourceRegistration resourceHandler) {
if (resourceHandler == null || resourceHandler.resource() == null) {
return Mono.error(new McpError("Resource must not be null"));
@ -401,7 +589,7 @@ public class McpAsyncServer {
.error(new McpError("Resource with URI '" + resourceHandler.resource().uri() + "' already exists"));
}
this.resources.put(resourceHandler.resource().uri(), resourceHandler);
this.resources.put(resourceHandler.resource().uri(), McpServer.mapDeprecatedResource(resourceHandler));
logger.info("Added resource handler: {}", resourceHandler.resource().uri());
if (this.serverCapabilities.resources().listChanged()) {
return notifyResourcesListChanged();
@ -422,15 +610,17 @@ public class McpAsyncServer {
return Mono.error(new McpError("Server must be configured with resource capabilities"));
}
ResourceRegistration removed = this.resources.remove(resourceUri);
if (removed != null) {
logger.info("Removed resource handler: {}", resourceUri);
if (this.serverCapabilities.resources().listChanged()) {
return notifyResourcesListChanged();
return Mono.defer(() -> {
McpServerFeatures.AsyncResourceRegistration removed = this.resources.remove(resourceUri);
if (removed != null) {
logger.info("Removed resource handler: {}", resourceUri);
if (this.serverCapabilities.resources().listChanged()) {
return notifyResourcesListChanged();
}
return Mono.empty();
}
return Mono.empty();
}
return Mono.error(new McpError("Resource with URI '" + resourceUri + "' not found"));
return Mono.error(new McpError("Resource with URI '" + resourceUri + "' not found"));
});
}
/**
@ -441,28 +631,30 @@ public class McpAsyncServer {
return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED, null);
}
private DefaultMcpSession.RequestHandler resourcesListRequestHandler() {
private DefaultMcpSession.RequestHandler<McpSchema.ListResourcesResult> resourcesListRequestHandler() {
return params -> {
var resourceList = this.resources.values().stream().map(ResourceRegistration::resource).toList();
var resourceList = this.resources.values()
.stream()
.map(McpServerFeatures.AsyncResourceRegistration::resource)
.toList();
return Mono.just(new McpSchema.ListResourcesResult(resourceList, null));
};
}
private DefaultMcpSession.RequestHandler resourceTemplateListRequestHandler() {
private DefaultMcpSession.RequestHandler<McpSchema.ListResourceTemplatesResult> resourceTemplateListRequestHandler() {
return params -> Mono.just(new McpSchema.ListResourceTemplatesResult(this.resourceTemplates, null));
}
private DefaultMcpSession.RequestHandler resourcesReadRequestHandler() {
private DefaultMcpSession.RequestHandler<McpSchema.ReadResourceResult> resourcesReadRequestHandler() {
return params -> {
McpSchema.ReadResourceRequest resourceRequest = transport.unmarshalFrom(params,
new TypeReference<McpSchema.ReadResourceRequest>() {
});
var resourceUri = resourceRequest.uri();
if (this.resources.containsKey(resourceUri)) {
return Mono.fromCallable(() -> this.resources.get(resourceUri).readHandler().apply(resourceRequest))
.map(result -> (Object) result)
.subscribeOn(Schedulers.boundedElastic());
McpServerFeatures.AsyncResourceRegistration registration = this.resources.get(resourceUri);
if (registration != null) {
return this.resources.get(resourceUri).readHandler().apply(resourceRequest);
}
return Mono.error(new McpError("Resource not found: " + resourceUri));
};
@ -477,6 +669,41 @@ public class McpAsyncServer {
* @param promptRegistration The prompt handler to add
* @return Mono that completes when clients have been notified of the change
*/
public Mono<Void> addPrompt(McpServerFeatures.AsyncPromptRegistration promptRegistration) {
if (promptRegistration == null) {
return Mono.error(new McpError("Prompt registration must not be null"));
}
if (this.serverCapabilities.prompts() == null) {
return Mono.error(new McpError("Server must be configured with prompt capabilities"));
}
return Mono.defer(() -> {
McpServerFeatures.AsyncPromptRegistration registration = this.prompts
.putIfAbsent(promptRegistration.prompt().name(), promptRegistration);
if (registration != null) {
return Mono.error(
new McpError("Prompt with name '" + promptRegistration.prompt().name() + "' already exists"));
}
logger.info("Added prompt handler: {}", promptRegistration.prompt().name());
// Servers that declared the listChanged capability SHOULD send a
// notification,
// when the list of available prompts changes
if (this.serverCapabilities.prompts().listChanged()) {
return notifyPromptsListChanged();
}
return Mono.empty();
});
}
/**
* Add a new prompt handler at runtime.
* @param promptRegistration The prompt handler to add
* @return Mono that completes when clients have been notified of the change
* @deprecated Use {@link #addPrompt(McpServerFeatures.AsyncPromptRegistration)}.
*/
@Deprecated
public Mono<Void> addPrompt(PromptRegistration promptRegistration) {
if (promptRegistration == null) {
return Mono.error(new McpError("Prompt registration must not be null"));
@ -490,7 +717,7 @@ public class McpAsyncServer {
.error(new McpError("Prompt with name '" + promptRegistration.prompt().name() + "' already exists"));
}
this.prompts.put(promptRegistration.prompt().name(), promptRegistration);
this.prompts.put(promptRegistration.prompt().name(), McpServer.mapDeprecatedPrompt(promptRegistration));
logger.info("Added prompt handler: {}", promptRegistration.prompt().name());
@ -515,18 +742,20 @@ public class McpAsyncServer {
return Mono.error(new McpError("Server must be configured with prompt capabilities"));
}
PromptRegistration removed = this.prompts.remove(promptName);
return Mono.defer(() -> {
McpServerFeatures.AsyncPromptRegistration removed = this.prompts.remove(promptName);
if (removed != null) {
logger.info("Removed prompt handler: {}", promptName);
// Servers that declared the listChanged capability SHOULD send a
// notification, when the list of available prompts changes
if (this.serverCapabilities.prompts().listChanged()) {
return this.notifyPromptsListChanged();
if (removed != null) {
logger.info("Removed prompt handler: {}", promptName);
// Servers that declared the listChanged capability SHOULD send a
// notification, when the list of available prompts changes
if (this.serverCapabilities.prompts().listChanged()) {
return this.notifyPromptsListChanged();
}
return Mono.empty();
}
return Mono.empty();
}
return Mono.error(new McpError("Prompt with name '" + promptName + "' not found"));
return Mono.error(new McpError("Prompt with name '" + promptName + "' not found"));
});
}
/**
@ -537,34 +766,35 @@ public class McpAsyncServer {
return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED, null);
}
private DefaultMcpSession.RequestHandler promptsListRequestHandler() {
private DefaultMcpSession.RequestHandler<McpSchema.ListPromptsResult> promptsListRequestHandler() {
return params -> {
// TODO: Implement pagination
// McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
// new TypeReference<McpSchema.PaginatedRequest>() {
// });
var promptList = this.prompts.values().stream().map(PromptRegistration::prompt).toList();
var promptList = this.prompts.values()
.stream()
.map(McpServerFeatures.AsyncPromptRegistration::prompt)
.toList();
return Mono.just(new McpSchema.ListPromptsResult(promptList, null));
};
}
private DefaultMcpSession.RequestHandler promptsGetRequestHandler() {
private DefaultMcpSession.RequestHandler<McpSchema.GetPromptResult> promptsGetRequestHandler() {
return params -> {
McpSchema.GetPromptRequest promptRequest = transport.unmarshalFrom(params,
new TypeReference<McpSchema.GetPromptRequest>() {
});
// Implement prompt retrieval logic here
if (this.prompts.containsKey(promptRequest.name())) {
return Mono
.fromCallable(() -> this.prompts.get(promptRequest.name()).promptHandler().apply(promptRequest))
.map(result -> (Object) result)
.subscribeOn(Schedulers.boundedElastic());
McpServerFeatures.AsyncPromptRegistration registration = this.prompts.get(promptRequest.name());
if (registration == null) {
return Mono.error(new McpError("Prompt not found: " + promptRequest.name()));
}
return Mono.error(new McpError("Prompt not found: " + promptRequest.name()));
return registration.promptHandler().apply(promptRequest);
};
}
@ -600,13 +830,10 @@ public class McpAsyncServer {
* not be sent.
* @return A handler that processes logging level change requests
*/
private DefaultMcpSession.RequestHandler setLoggerRequestHandler() {
private DefaultMcpSession.RequestHandler<Void> setLoggerRequestHandler() {
return params -> {
McpSchema.LoggingLevel setLoggerRequest = transport.unmarshalFrom(params,
new TypeReference<McpSchema.LoggingLevel>() {
});
this.minLoggingLevel = setLoggerRequest;
this.minLoggingLevel = transport.unmarshalFrom(params, new TypeReference<LoggingLevel>() {
});
return Mono.empty();
};
@ -615,7 +842,7 @@ public class McpAsyncServer {
// ---------------------------------------
// Sampling
// ---------------------------------------
private static TypeReference<McpSchema.CreateMessageResult> CREATE_MESSAGE_RESULT_TYPE_REF = new TypeReference<>() {
private static final TypeReference<McpSchema.CreateMessageResult> CREATE_MESSAGE_RESULT_TYPE_REF = new TypeReference<>() {
};
/**

View file

@ -0,0 +1,399 @@
package org.springframework.ai.mcp.server;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Function;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import org.springframework.ai.mcp.spec.McpSchema;
import org.springframework.ai.mcp.util.Assert;
import org.springframework.ai.mcp.util.Utils;
/**
* MCP server features specification that a particular server can choose to support.
*/
public class McpServerFeatures {
/**
* Asynchronous server features specification.
*
* @param serverInfo The server implementation details
* @param serverCapabilities The server capabilities
* @param tools The list of tool registrations
* @param resources The map of resource registrations
* @param resourceTemplates The list of resource templates
* @param prompts The map of prompt registrations
* @param rootsChangeConsumers The list of consumers that will be notified when the
* roots list changes
*/
record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List<McpServerFeatures.AsyncToolRegistration> tools, Map<String, AsyncResourceRegistration> resources,
List<McpSchema.ResourceTemplate> resourceTemplates,
Map<String, McpServerFeatures.AsyncPromptRegistration> prompts,
List<Function<List<McpSchema.Root>, Mono<Void>>> rootsChangeConsumers) {
/**
* Create an instance and validate the arguments.
* @param serverInfo The server implementation details
* @param serverCapabilities The server capabilities
* @param tools The list of tool registrations
* @param resources The map of resource registrations
* @param resourceTemplates The list of resource templates
* @param prompts The map of prompt registrations
* @param rootsChangeConsumers The list of consumers that will be notified when
* the roots list changes
*/
Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List<McpServerFeatures.AsyncToolRegistration> tools, Map<String, AsyncResourceRegistration> resources,
List<McpSchema.ResourceTemplate> resourceTemplates,
Map<String, McpServerFeatures.AsyncPromptRegistration> prompts,
List<Function<List<McpSchema.Root>, Mono<Void>>> rootsChangeConsumers) {
Assert.notNull(serverInfo, "Server info must not be null");
this.serverInfo = serverInfo;
this.serverCapabilities = (serverCapabilities != null) ? serverCapabilities
: new McpSchema.ServerCapabilities(null, // experimental
new McpSchema.ServerCapabilities.LoggingCapabilities(), // Enable
// logging
// by
// default
!Utils.isEmpty(prompts) ? new McpSchema.ServerCapabilities.PromptCapabilities(false) : null,
!Utils.isEmpty(resources)
? new McpSchema.ServerCapabilities.ResourceCapabilities(false, false) : null,
!Utils.isEmpty(tools) ? new McpSchema.ServerCapabilities.ToolCapabilities(false) : null);
this.tools = (tools != null) ? tools : List.of();
this.resources = (resources != null) ? resources : Map.of();
this.resourceTemplates = (resourceTemplates != null) ? resourceTemplates : List.of();
this.prompts = (prompts != null) ? prompts : Map.of();
this.rootsChangeConsumers = (rootsChangeConsumers != null) ? rootsChangeConsumers : List.of();
}
/**
* Convert a synchronous specification into an asynchronous one and provide
* blocking code offloading to prevent accidental blocking of the non-blocking
* transport.
* @param syncSpec a potentially blocking, synchronous specification.
* @return a specification which is protected from blocking calls specified by the
* user.
*/
static Async fromSync(Sync syncSpec) {
List<McpServerFeatures.AsyncToolRegistration> tools = new ArrayList<>();
for (var tool : syncSpec.tools()) {
tools.add(AsyncToolRegistration.fromSync(tool));
}
Map<String, AsyncResourceRegistration> resources = new HashMap<>();
syncSpec.resources().forEach((key, resource) -> {
resources.put(key, AsyncResourceRegistration.fromSync(resource));
});
Map<String, AsyncPromptRegistration> prompts = new HashMap<>();
syncSpec.prompts().forEach((key, prompt) -> {
prompts.put(key, AsyncPromptRegistration.fromSync(prompt));
});
List<Function<List<McpSchema.Root>, Mono<Void>>> rootChangeConsumers = new ArrayList<>();
for (var rootChangeConsumer : syncSpec.rootsChangeConsumers()) {
rootChangeConsumers.add(list -> Mono.<Void>fromRunnable(() -> rootChangeConsumer.accept(list))
.subscribeOn(Schedulers.boundedElastic()));
}
return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources,
syncSpec.resourceTemplates(), prompts, rootChangeConsumers);
}
}
/**
* Synchronous server features specification.
*
* @param serverInfo The server implementation details
* @param serverCapabilities The server capabilities
* @param tools The list of tool registrations
* @param resources The map of resource registrations
* @param resourceTemplates The list of resource templates
* @param prompts The map of prompt registrations
* @param rootsChangeConsumers The list of consumers that will be notified when the
* roots list changes
*/
record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List<McpServerFeatures.SyncToolRegistration> tools,
Map<String, McpServerFeatures.SyncResourceRegistration> resources,
List<McpSchema.ResourceTemplate> resourceTemplates,
Map<String, McpServerFeatures.SyncPromptRegistration> prompts,
List<Consumer<List<McpSchema.Root>>> rootsChangeConsumers) {
/**
* Create an instance and validate the arguments.
* @param serverInfo The server implementation details
* @param serverCapabilities The server capabilities
* @param tools The list of tool registrations
* @param resources The map of resource registrations
* @param resourceTemplates The list of resource templates
* @param prompts The map of prompt registrations
* @param rootsChangeConsumers The list of consumers that will be notified when
* the roots list changes
*/
Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List<McpServerFeatures.SyncToolRegistration> tools,
Map<String, McpServerFeatures.SyncResourceRegistration> resources,
List<McpSchema.ResourceTemplate> resourceTemplates,
Map<String, McpServerFeatures.SyncPromptRegistration> prompts,
List<Consumer<List<McpSchema.Root>>> rootsChangeConsumers) {
Assert.notNull(serverInfo, "Server info must not be null");
this.serverInfo = serverInfo;
this.serverCapabilities = (serverCapabilities != null) ? serverCapabilities
: new McpSchema.ServerCapabilities(null, // experimental
new McpSchema.ServerCapabilities.LoggingCapabilities(), // Enable
// logging
// by
// default
!Utils.isEmpty(prompts) ? new McpSchema.ServerCapabilities.PromptCapabilities(false) : null,
!Utils.isEmpty(resources)
? new McpSchema.ServerCapabilities.ResourceCapabilities(false, false) : null,
!Utils.isEmpty(tools) ? new McpSchema.ServerCapabilities.ToolCapabilities(false) : null);
this.tools = (tools != null) ? tools : new ArrayList<>();
this.resources = (resources != null) ? resources : new HashMap<>();
this.resourceTemplates = (resourceTemplates != null) ? resourceTemplates : new ArrayList<>();
this.prompts = (prompts != null) ? prompts : new HashMap<>();
this.rootsChangeConsumers = (rootsChangeConsumers != null) ? rootsChangeConsumers : new ArrayList<>();
}
}
/**
* Registration of a tool with its asynchronous handler function. Tools are the
* primary way for MCP servers to expose functionality to AI models. Each tool
* represents a specific capability, such as:
* <ul>
* <li>Performing calculations
* <li>Accessing external APIs
* <li>Querying databases
* <li>Manipulating files
* <li>Executing system commands
* </ul>
*
* <p>
* Example tool registration: <pre>{@code
* new McpServerFeatures.AsyncToolRegistration(
* new Tool(
* "calculator",
* "Performs mathematical calculations",
* new JsonSchemaObject()
* .required("expression")
* .property("expression", JsonSchemaType.STRING)
* ),
* args -> {
* String expr = (String) args.get("expression");
* return Mono.just(new CallToolResult("Result: " + evaluate(expr)));
* }
* )
* }</pre>
*
* @param tool The tool definition including name, description, and parameter schema
* @param call The function that implements the tool's logic, receiving arguments and
* returning results
*/
public record AsyncToolRegistration(McpSchema.Tool tool,
Function<Map<String, Object>, Mono<McpSchema.CallToolResult>> call) {
static AsyncToolRegistration fromSync(SyncToolRegistration tool) {
// FIXME: This is temporary, proper validation should be implemented
if (tool == null) {
return null;
}
return new AsyncToolRegistration(tool.tool(),
map -> Mono.fromCallable(() -> tool.call().apply(map)).subscribeOn(Schedulers.boundedElastic()));
}
}
/**
* Registration of a resource with its asynchronous handler function. Resources
* provide context to AI models by exposing data such as:
* <ul>
* <li>File contents
* <li>Database records
* <li>API responses
* <li>System information
* <li>Application state
* </ul>
*
* <p>
* Example resource registration: <pre>{@code
* new McpServerFeatures.AsyncResourceRegistration(
* new Resource("docs", "Documentation files", "text/markdown"),
* request -> {
* String content = readFile(request.getPath());
* return Mono.just(new ReadResourceResult(content));
* }
* )
* }</pre>
*
* @param resource The resource definition including name, description, and MIME type
* @param readHandler The function that handles resource read requests
*/
public record AsyncResourceRegistration(McpSchema.Resource resource,
Function<McpSchema.ReadResourceRequest, Mono<McpSchema.ReadResourceResult>> readHandler) {
static AsyncResourceRegistration fromSync(SyncResourceRegistration resource) {
// FIXME: This is temporary, proper validation should be implemented
if (resource == null) {
return null;
}
return new AsyncResourceRegistration(resource.resource(),
req -> Mono.fromCallable(() -> resource.readHandler().apply(req))
.subscribeOn(Schedulers.boundedElastic()));
}
}
/**
* Registration of a prompt template with its asynchronous handler function. Prompts
* provide structured templates for AI model interactions, supporting:
* <ul>
* <li>Consistent message formatting
* <li>Parameter substitution
* <li>Context injection
* <li>Response formatting
* <li>Instruction templating
* </ul>
*
* <p>
* Example prompt registration: <pre>{@code
* new McpServerFeatures.AsyncPromptRegistration(
* new Prompt("analyze", "Code analysis template"),
* request -> {
* String code = request.getArguments().get("code");
* return Mono.just(new GetPromptResult(
* "Analyze this code:\n\n" + code + "\n\nProvide feedback on:"
* ));
* }
* )
* }</pre>
*
* @param prompt The prompt definition including name and description
* @param promptHandler The function that processes prompt requests and returns
* formatted templates
*/
public record AsyncPromptRegistration(McpSchema.Prompt prompt,
Function<McpSchema.GetPromptRequest, Mono<McpSchema.GetPromptResult>> promptHandler) {
static AsyncPromptRegistration fromSync(SyncPromptRegistration prompt) {
// FIXME: This is temporary, proper validation should be implemented
if (prompt == null) {
return null;
}
return new AsyncPromptRegistration(prompt.prompt(),
req -> Mono.fromCallable(() -> prompt.promptHandler().apply(req))
.subscribeOn(Schedulers.boundedElastic()));
}
}
/**
* Registration of a tool with its synchronous handler function. Tools are the primary
* way for MCP servers to expose functionality to AI models. Each tool represents a
* specific capability, such as:
* <ul>
* <li>Performing calculations
* <li>Accessing external APIs
* <li>Querying databases
* <li>Manipulating files
* <li>Executing system commands
* </ul>
*
* <p>
* Example tool registration: <pre>{@code
* new McpServerFeatures.SyncToolRegistration(
* new Tool(
* "calculator",
* "Performs mathematical calculations",
* new JsonSchemaObject()
* .required("expression")
* .property("expression", JsonSchemaType.STRING)
* ),
* args -> {
* String expr = (String) args.get("expression");
* return new CallToolResult("Result: " + evaluate(expr));
* }
* )
* }</pre>
*
* @param tool The tool definition including name, description, and parameter schema
* @param call The function that implements the tool's logic, receiving arguments and
* returning results
*/
public record SyncToolRegistration(McpSchema.Tool tool,
Function<Map<String, Object>, McpSchema.CallToolResult> call) {
}
/**
* Registration of a resource with its synchronous handler function. Resources provide
* context to AI models by exposing data such as:
* <ul>
* <li>File contents
* <li>Database records
* <li>API responses
* <li>System information
* <li>Application state
* </ul>
*
* <p>
* Example resource registration: <pre>{@code
* new McpServerFeatures.SyncResourceRegistration(
* new Resource("docs", "Documentation files", "text/markdown"),
* request -> {
* String content = readFile(request.getPath());
* return new ReadResourceResult(content);
* }
* )
* }</pre>
*
* @param resource The resource definition including name, description, and MIME type
* @param readHandler The function that handles resource read requests
*/
public record SyncResourceRegistration(McpSchema.Resource resource,
Function<McpSchema.ReadResourceRequest, McpSchema.ReadResourceResult> readHandler) {
}
/**
* Registration of a prompt template with its synchronous handler function. Prompts
* provide structured templates for AI model interactions, supporting:
* <ul>
* <li>Consistent message formatting
* <li>Parameter substitution
* <li>Context injection
* <li>Response formatting
* <li>Instruction templating
* </ul>
*
* <p>
* Example prompt registration: <pre>{@code
* new McpServerFeatures.SyncPromptRegistration(
* new Prompt("analyze", "Code analysis template"),
* request -> {
* String code = request.getArguments().get("code");
* return new GetPromptResult(
* "Analyze this code:\n\n" + code + "\n\nProvide feedback on:"
* );
* }
* )
* }</pre>
*
* @param prompt The prompt definition including name and description
* @param promptHandler The function that processes prompt requests and returns
* formatted templates
*/
public record SyncPromptRegistration(McpSchema.Prompt prompt,
Function<McpSchema.GetPromptRequest, McpSchema.GetPromptResult> promptHandler) {
}
}

View file

@ -26,11 +26,42 @@ import org.springframework.ai.mcp.spec.McpSchema.LoggingMessageNotification;
import org.springframework.ai.mcp.util.Assert;
/**
* Synchronous wrapper around {@link McpAsyncServer} that provides blocking operations.
* This class delegates all operations to an underlying async server instance while
* providing a synchronous API.
* A synchronous implementation of the Model Context Protocol (MCP) server that wraps
* {@link McpAsyncServer} to provide blocking operations. This class delegates all
* operations to an underlying async server instance while providing a simpler,
* synchronous API for scenarios where reactive programming is not required.
*
* <p>
* The MCP server enables AI models to expose tools, resources, and prompts through a
* standardized interface. Key features available through this synchronous API include:
* <ul>
* <li>Tool registration and management for extending AI model capabilities
* <li>Resource handling with URI-based addressing for providing context
* <li>Prompt template management for standardized interactions
* <li>Real-time client notifications for state changes
* <li>Structured logging with configurable severity levels
* <li>Support for client-side AI model sampling
* </ul>
*
* <p>
* While {@link McpAsyncServer} uses Project Reactor's Mono and Flux types for
* non-blocking operations, this class converts those into blocking calls, making it more
* suitable for:
* <ul>
* <li>Traditional synchronous applications
* <li>Simple scripting scenarios
* <li>Testing and debugging
* <li>Cases where reactive programming adds unnecessary complexity
* </ul>
*
* <p>
* The server supports runtime modification of its capabilities through methods like
* {@link #addTool}, {@link #addResource}, and {@link #addPrompt}, automatically notifying
* connected clients of changes when configured to do so.
*
* @author Christian Tzolov
* @see McpAsyncServer
* @see McpSchema
*/
public class McpSyncServer {
@ -69,6 +100,16 @@ public class McpSyncServer {
* Add a new tool handler.
* @param toolHandler The tool handler to add
*/
public void addTool(McpServerFeatures.SyncToolRegistration toolHandler) {
this.asyncServer.addTool(McpServerFeatures.AsyncToolRegistration.fromSync(toolHandler)).block();
}
/**
* Add a new tool handler.
* @param toolHandler The tool handler to add
* @deprecated Use {@link #addTool(McpServerFeatures.SyncToolRegistration)}.
*/
@Deprecated
public void addTool(ToolRegistration toolHandler) {
this.asyncServer.addTool(toolHandler).block();
}
@ -85,6 +126,16 @@ public class McpSyncServer {
* Add a new resource handler.
* @param resourceHandler The resource handler to add
*/
public void addResource(McpServerFeatures.SyncResourceRegistration resourceHandler) {
this.asyncServer.addResource(McpServerFeatures.AsyncResourceRegistration.fromSync(resourceHandler)).block();
}
/**
* Add a new resource handler.
* @param resourceHandler The resource handler to add
* @deprecated Use {@link #addResource(McpServerFeatures.SyncResourceRegistration)}.
*/
@Deprecated
public void addResource(ResourceRegistration resourceHandler) {
this.asyncServer.addResource(resourceHandler).block();
}
@ -101,6 +152,16 @@ public class McpSyncServer {
* Add a new prompt handler.
* @param promptRegistration The prompt registration to add
*/
public void addPrompt(McpServerFeatures.SyncPromptRegistration promptRegistration) {
this.asyncServer.addPrompt(McpServerFeatures.AsyncPromptRegistration.fromSync(promptRegistration)).block();
}
/**
* Add a new prompt handler.
* @param promptRegistration The prompt registration to add
* @deprecated Use {@link #addPrompt(McpServerFeatures.SyncPromptRegistration)}.
*/
@Deprecated
public void addPrompt(PromptRegistration promptRegistration) {
this.asyncServer.addPrompt(promptRegistration).block();
}
@ -120,28 +181,6 @@ public class McpSyncServer {
this.asyncServer.notifyToolsListChanged().block();
}
/**
* Notify clients that the list of available resources has changed.
*/
public void notifyResourcesListChanged() {
this.asyncServer.notifyResourcesListChanged().block();
}
/**
* Notify clients that the list of available prompts has changed.
*/
public void notifyPromptsListChanged() {
this.asyncServer.notifyPromptsListChanged().block();
}
/**
* Send a logging message notification to all clients.
* @param loggingMessageNotification The logging message notification to send
*/
public void loggingNotification(LoggingMessageNotification loggingMessageNotification) {
this.asyncServer.loggingNotification(loggingMessageNotification).block();
}
/**
* Get the server capabilities that define the supported features and functionality.
* @return The server capabilities
@ -174,6 +213,28 @@ public class McpSyncServer {
return this.asyncServer.getClientInfo();
}
/**
* Notify clients that the list of available resources has changed.
*/
public void notifyResourcesListChanged() {
this.asyncServer.notifyResourcesListChanged().block();
}
/**
* Notify clients that the list of available prompts has changed.
*/
public void notifyPromptsListChanged() {
this.asyncServer.notifyPromptsListChanged().block();
}
/**
* Send a logging message notification to all clients.
* @param loggingMessageNotification The logging message notification to send
*/
public void loggingNotification(LoggingMessageNotification loggingMessageNotification) {
this.asyncServer.loggingNotification(loggingMessageNotification).block();
}
/**
* Close the server gracefully.
*/
@ -199,13 +260,19 @@ public class McpSyncServer {
/**
* Create a new message using the sampling capabilities of the client. The Model
* Context Protocol (MCP) provides a standardized way for servers to request LLM
* sampling (“completions” or “generations”) from language models via clients. This
* flow allows clients to maintain control over model access, selection, and
* sampling ("completions" or "generations") from language models via clients.
*
* <p>
* This flow allows clients to maintain control over model access, selection, and
* permissions while enabling servers to leverage AI capabilities—with no server API
* keys necessary. Servers can request text or image-based interactions and optionally
* include context from MCP servers in their prompts.
*
* <p>
* Unlike its async counterpart, this method blocks until the message creation is
* complete, making it easier to use in synchronous code paths.
* @param createMessageRequest The request to create a new message
* @return A Mono that completes when the message has been created
* @return The result of the message creation
* @throws McpError if the client has not been initialized or does not support
* sampling capabilities
* @throws McpError if the client does not support the createMessage method

View file

@ -63,7 +63,7 @@ public class DefaultMcpSession implements McpSession {
private final ConcurrentHashMap<Object, MonoSink<McpSchema.JSONRPCResponse>> pendingResponses = new ConcurrentHashMap<>();
/** Map of request handlers keyed by method name */
private final ConcurrentHashMap<String, RequestHandler> requestHandlers = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, RequestHandler<?>> requestHandlers = new ConcurrentHashMap<>();
/** Map of notification handlers keyed by method name */
private final ConcurrentHashMap<String, NotificationHandler> notificationHandlers = new ConcurrentHashMap<>();
@ -79,16 +79,18 @@ public class DefaultMcpSession implements McpSession {
/**
* Functional interface for handling incoming JSON-RPC requests. Implementations
* should process the request parameters and return a response.
*
* @param <T> Response type
*/
@FunctionalInterface
public interface RequestHandler {
public interface RequestHandler<T> {
/**
* Handles an incoming request with the given parameters.
* @param params The request parameters
* @return A Mono containing the response object
*/
Mono<Object> handle(Object params);
Mono<T> handle(Object params);
}
@ -116,7 +118,7 @@ public class DefaultMcpSession implements McpSession {
* @param notificationHandlers Map of method names to notification handlers
*/
public DefaultMcpSession(Duration requestTimeout, McpTransport transport,
Map<String, RequestHandler> requestHandlers, Map<String, NotificationHandler> notificationHandlers) {
Map<String, RequestHandler<?>> requestHandlers, Map<String, NotificationHandler> notificationHandlers) {
Assert.notNull(requestTimeout, "The requstTimeout can not be null");
Assert.notNull(transport, "The transport can not be null");

View file

@ -25,6 +25,7 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import org.springframework.ai.mcp.spec.ClientMcpTransport;
@ -77,10 +78,10 @@ public abstract class AbstractMcpAsyncClientTests {
this.mcpTransport = createMcpTransport();
assertThatCode(() -> {
mcpAsyncClient = McpClient.using(mcpTransport)
mcpAsyncClient = McpClient.async(mcpTransport)
.requestTimeout(TIMEOUT)
.capabilities(ClientCapabilities.builder().roots(true).build())
.async();
.build();
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
}).doesNotThrowAnyException();
}
@ -96,10 +97,10 @@ public abstract class AbstractMcpAsyncClientTests {
@Test
void testConstructorWithInvalidArguments() {
assertThatThrownBy(() -> McpClient.using(null).sync()).isInstanceOf(IllegalArgumentException.class)
assertThatThrownBy(() -> McpClient.sync(null).build()).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Transport must not be null");
assertThatThrownBy(() -> McpClient.using(mcpTransport).requestTimeout(null).sync())
assertThatThrownBy(() -> McpClient.sync(mcpTransport).requestTimeout(null).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Request timeout must not be null");
}
@ -195,10 +196,10 @@ public abstract class AbstractMcpAsyncClientTests {
void testInitializeWithRootsListProviders() {
var transport = createMcpTransport();
var client = McpClient.using(transport)
var client = McpClient.async(transport)
.requestTimeout(TIMEOUT)
.roots(new Root("file:///test/path", "test-root"))
.async();
.build();
assertThatCode(() -> client.initialize().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
@ -277,12 +278,12 @@ public abstract class AbstractMcpAsyncClientTests {
AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false);
var transport = createMcpTransport();
var client = McpClient.using(transport)
var client = McpClient.async(transport)
.requestTimeout(TIMEOUT)
.toolsChangeConsumer(tools -> toolsNotificationReceived.set(true))
.resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true))
.promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true))
.async();
.toolsChangeConsumer(tools -> Mono.fromRunnable(() -> toolsNotificationReceived.set(true)))
.resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true)))
.promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true)))
.build();
assertThatCode(() -> {
client.initialize().block();
@ -299,9 +300,11 @@ public abstract class AbstractMcpAsyncClientTests {
var capabilities = ClientCapabilities.builder().sampling().build();
var client = McpClient.using(transport).requestTimeout(TIMEOUT).capabilities(capabilities).sampling(request -> {
return CreateMessageResult.builder().message("test").model("test-model").build();
}).async();
var client = McpClient.async(transport)
.requestTimeout(TIMEOUT)
.capabilities(capabilities)
.sampling(request -> Mono.just(CreateMessageResult.builder().message("test").model("test-model").build()))
.build();
assertThatCode(() -> {
client.initialize().block(Duration.ofSeconds(10));
@ -319,14 +322,13 @@ public abstract class AbstractMcpAsyncClientTests {
.sampling()
.build();
Function<CreateMessageRequest, CreateMessageResult> samplingHandler = request -> {
return CreateMessageResult.builder().message("test").model("test-model").build();
};
var client = McpClient.using(transport)
Function<CreateMessageRequest, Mono<CreateMessageResult>> samplingHandler = request -> Mono
.just(CreateMessageResult.builder().message("test").model("test-model").build());
var client = McpClient.async(transport)
.requestTimeout(TIMEOUT)
.capabilities(capabilities)
.sampling(samplingHandler)
.async();
.build();
assertThatCode(() -> {
var result = client.initialize().block(Duration.ofSeconds(10));
@ -353,10 +355,10 @@ public abstract class AbstractMcpAsyncClientTests {
AtomicBoolean logReceived = new AtomicBoolean(false);
var transport = createMcpTransport();
var client = McpClient.using(transport)
var client = McpClient.async(transport)
.requestTimeout(TIMEOUT)
.loggingConsumer(notification -> logReceived.set(true))
.async();
.loggingConsumer(notification -> Mono.fromRunnable(() -> logReceived.set(true)))
.build();
assertThatCode(() -> {
client.initialize().block(Duration.ofSeconds(10));

View file

@ -72,10 +72,10 @@ public abstract class AbstractMcpSyncClientTests {
this.mcpTransport = createMcpTransport();
assertThatCode(() -> {
mcpSyncClient = McpClient.using(mcpTransport)
mcpSyncClient = McpClient.sync(mcpTransport)
.requestTimeout(TIMEOUT)
.capabilities(ClientCapabilities.builder().roots(true).build())
.sync();
.build();
mcpSyncClient.initialize();
}).doesNotThrowAnyException();
}
@ -90,10 +90,10 @@ public abstract class AbstractMcpSyncClientTests {
@Test
void testConstructorWithInvalidArguments() {
assertThatThrownBy(() -> McpClient.using(null).sync()).isInstanceOf(IllegalArgumentException.class)
assertThatThrownBy(() -> McpClient.sync(null).build()).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Transport must not be null");
assertThatThrownBy(() -> McpClient.using(mcpTransport).requestTimeout(null).sync())
assertThatThrownBy(() -> McpClient.sync(mcpTransport).requestTimeout(null).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Request timeout must not be null");
}
@ -180,10 +180,10 @@ public abstract class AbstractMcpSyncClientTests {
void testInitializeWithRootsListProviders() {
var transport = createMcpTransport();
var client = McpClient.using(transport)
var client = McpClient.sync(transport)
.requestTimeout(TIMEOUT)
.roots(new Root("file:///test/path", "test-root"))
.sync();
.build();
assertThatCode(() -> {
client.initialize();
@ -262,12 +262,12 @@ public abstract class AbstractMcpSyncClientTests {
AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false);
var transport = createMcpTransport();
var client = McpClient.using(transport)
var client = McpClient.sync(transport)
.requestTimeout(TIMEOUT)
.toolsChangeConsumer(tools -> toolsNotificationReceived.set(true))
.resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true))
.promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true))
.sync();
.build();
assertThatCode(() -> {
client.initialize();
@ -295,10 +295,10 @@ public abstract class AbstractMcpSyncClientTests {
AtomicBoolean logReceived = new AtomicBoolean(false);
var transport = createMcpTransport();
var client = McpClient.using(transport)
var client = McpClient.sync(transport)
.requestTimeout(TIMEOUT)
.loggingConsumer(notification -> logReceived.set(true))
.sync();
.build();
assertThatCode(() -> {
client.initialize();

View file

@ -27,6 +27,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import org.springframework.ai.mcp.MockMcpTransport;
import org.springframework.ai.mcp.spec.McpError;
@ -48,12 +49,11 @@ class McpAsyncClientResponseHandlerTests {
List<McpSchema.Tool> receivedTools = new ArrayList<>();
// Create a consumer that will be called when tools change
Consumer<List<McpSchema.Tool>> toolsChangeConsumer = tools -> {
receivedTools.addAll(tools);
};
Function<List<McpSchema.Tool>, Mono<Void>> toolsChangeConsumer = tools -> Mono
.fromRunnable(() -> receivedTools.addAll(tools));
// Create client with tools change consumer
McpAsyncClient asyncMcpClient = McpClient.using(transport).toolsChangeConsumer(toolsChangeConsumer).async();
McpAsyncClient asyncMcpClient = McpClient.async(transport).toolsChangeConsumer(toolsChangeConsumer).build();
// Create a mock tools list that the server will return
Map<String, Object> inputSchema = Map.of("type", "object", "properties", Map.of(), "required", List.of());
@ -88,9 +88,9 @@ class McpAsyncClientResponseHandlerTests {
void testRootsListRequestHandling() {
MockMcpTransport transport = new MockMcpTransport();
McpAsyncClient asyncMcpClient = McpClient.using(transport)
McpAsyncClient asyncMcpClient = McpClient.async(transport)
.roots(new Root("file:///test/path", "test-root"))
.async();
.build();
// Simulate incoming request
McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION,
@ -118,14 +118,13 @@ class McpAsyncClientResponseHandlerTests {
List<McpSchema.Resource> receivedResources = new ArrayList<>();
// Create a consumer that will be called when resources change
Consumer<List<McpSchema.Resource>> resourcesChangeConsumer = resources -> {
receivedResources.addAll(resources);
};
Function<List<McpSchema.Resource>, Mono<Void>> resourcesChangeConsumer = resources -> Mono
.fromRunnable(() -> receivedResources.addAll(resources));
// Create client with resources change consumer
McpAsyncClient asyncMcpClient = McpClient.using(transport)
McpAsyncClient asyncMcpClient = McpClient.async(transport)
.resourcesChangeConsumer(resourcesChangeConsumer)
.async();
.build();
// Create a mock resources list that the server will return
McpSchema.Resource mockResource = new McpSchema.Resource("test://resource", "Test Resource", "A test resource",
@ -165,12 +164,11 @@ class McpAsyncClientResponseHandlerTests {
List<McpSchema.Prompt> receivedPrompts = new ArrayList<>();
// Create a consumer that will be called when prompts change
Consumer<List<McpSchema.Prompt>> promptsChangeConsumer = prompts -> {
receivedPrompts.addAll(prompts);
};
Function<List<McpSchema.Prompt>, Mono<Void>> promptsChangeConsumer = prompts -> Mono
.fromRunnable(() -> receivedPrompts.addAll(prompts));
// Create client with prompts change consumer
McpAsyncClient asyncMcpClient = McpClient.using(transport).promptsChangeConsumer(promptsChangeConsumer).async();
McpAsyncClient asyncMcpClient = McpClient.async(transport).promptsChangeConsumer(promptsChangeConsumer).build();
// Create a mock prompts list that the server will return
McpSchema.Prompt mockPrompt = new McpSchema.Prompt("test-prompt", "Test Prompt Description",
@ -207,17 +205,17 @@ class McpAsyncClientResponseHandlerTests {
MockMcpTransport transport = new MockMcpTransport();
// Create a test sampling handler that echoes back the input
Function<McpSchema.CreateMessageRequest, McpSchema.CreateMessageResult> samplingHandler = request -> {
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler = request -> {
var content = request.messages().get(0).content();
return new McpSchema.CreateMessageResult(McpSchema.Role.ASSISTANT, content, "test-model",
McpSchema.CreateMessageResult.StopReason.END_TURN);
return Mono.just(new McpSchema.CreateMessageResult(McpSchema.Role.ASSISTANT, content, "test-model",
McpSchema.CreateMessageResult.StopReason.END_TURN));
};
// Create client with sampling capability and handler
McpAsyncClient asyncMcpClient = McpClient.using(transport)
McpAsyncClient asyncMcpClient = McpClient.async(transport)
.capabilities(ClientCapabilities.builder().sampling().build())
.sampling(samplingHandler)
.async();
.build();
// Create a mock create message request
var messageRequest = new McpSchema.CreateMessageRequest(
@ -259,9 +257,9 @@ class McpAsyncClientResponseHandlerTests {
MockMcpTransport transport = new MockMcpTransport();
// Create client without sampling capability
McpAsyncClient asyncMcpClient = McpClient.using(transport)
McpAsyncClient asyncMcpClient = McpClient.async(transport)
.capabilities(ClientCapabilities.builder().build()) // No sampling capability
.async();
.build();
// Create a mock create message request
var messageRequest = new McpSchema.CreateMessageRequest(
@ -292,7 +290,7 @@ class McpAsyncClientResponseHandlerTests {
// Create client with sampling capability but null handler
assertThatThrownBy(
() -> McpClient.using(transport).capabilities(ClientCapabilities.builder().sampling().build()).async())
() -> McpClient.async(transport).capabilities(ClientCapabilities.builder().sampling().build()).build())
.isInstanceOf(McpError.class)
.hasMessage("Sampling handler must not be null when client capabilities include sampling");
}

Some files were not shown because too many files have changed in this diff Show more