feat: add structured logging support with severity levels
- Add client and server-side logging capabilities with configurable severity levels
- Implement logging message notifications with DEBUG to EMERGENCY levels
- Add logging consumers for client-side log handling
- Update documentation with logging examples and configuration
- Add comprehensive tests for logging functionality
- Fix typo in README.md (notificaiotn -> notification)
- Centralize MCP method names as constants
- Move all JSON-RPC method names to McpSchema class as static constants
- Replace hardcoded method strings with constant references in McpAsyncClient
and McpAsyncServer
- Update test cases to use new method name constants
Resolves #35
This commit is contained in:
parent
a5394fffd2
commit
86659344fc
16 changed files with 861 additions and 228 deletions
|
|
@ -16,7 +16,7 @@ The project consists of two main modules:
|
||||||
|
|
||||||
Java implementation of the Model Context Protocol specification. It includes:
|
Java implementation of the Model Context Protocol specification. It includes:
|
||||||
- Synchronous and asynchronous [MCP Client](https://github.com/spring-projects-experimental/spring-ai-mcp/blob/main/mcp/README.md#client-usage-examples) and [MCP Server](https://github.com/spring-projects-experimental/spring-ai-mcp/blob/main/mcp/README.md#server-usage-examples) implementations
|
- Synchronous and asynchronous [MCP Client](https://github.com/spring-projects-experimental/spring-ai-mcp/blob/main/mcp/README.md#client-usage-examples) and [MCP Server](https://github.com/spring-projects-experimental/spring-ai-mcp/blob/main/mcp/README.md#server-usage-examples) implementations
|
||||||
- Standard MCP operations support (tool discovery, resource management, prompt handling). Support for request and notificaiotn handling.
|
- Standard MCP operations support (tool discovery, resource management, prompt handling, structured logging). Support for request and notification handling.
|
||||||
- [Stdio](https://spec.modelcontextprotocol.io/specification/basic/transports/#stdio) and [SSE](https://spec.modelcontextprotocol.io/specification/basic/transports/#http-with-sse) transport implementations.
|
- [Stdio](https://spec.modelcontextprotocol.io/specification/basic/transports/#stdio) and [SSE](https://spec.modelcontextprotocol.io/specification/basic/transports/#http-with-sse) transport implementations.
|
||||||
- [Find more](./mcp/README.md).
|
- [Find more](./mcp/README.md).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,10 +136,6 @@ McpServer.using(transport)
|
||||||
.sync();
|
.sync();
|
||||||
```
|
```
|
||||||
|
|
||||||
### Implementation Notes
|
|
||||||
|
|
||||||
- The server factory uses a builder pattern for configuration
|
|
||||||
- Default server info is "mcp-server" version "1.0.0" if not specified
|
|
||||||
- Tool handlers must be thread-safe as they may be called concurrently
|
- Tool handlers must be thread-safe as they may be called concurrently
|
||||||
- The synchronous server is implemented as a wrapper around the asynchronous server
|
- The synchronous server is implemented as a wrapper around the asynchronous server
|
||||||
- Resource and prompt providers use cursor-based pagination
|
- Resource and prompt providers use cursor-based pagination
|
||||||
|
|
@ -160,3 +156,59 @@ Tool handlers implement this interface to define:
|
||||||
- Tool name and description
|
- Tool name and description
|
||||||
- Input schema for validation
|
- Input schema for validation
|
||||||
- Execution logic in the call method
|
- Execution logic in the call method
|
||||||
|
|
||||||
|
### Logging Capabilities
|
||||||
|
|
||||||
|
The MCP Server supports logging functionality that allows sending log messages with different severity levels to the Client. This feature can be enabled through server capabilities configuration.
|
||||||
|
|
||||||
|
#### Enabling Logging
|
||||||
|
|
||||||
|
```java
|
||||||
|
McpServer.using(transport)
|
||||||
|
.serverInfo("my-server", "1.0.0")
|
||||||
|
.capabilities(ServerCapabilities.builder().logging().build())
|
||||||
|
.sync(); // or .async()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Logging Levels
|
||||||
|
|
||||||
|
The server supports the following logging levels:
|
||||||
|
- TRACE
|
||||||
|
- DEBUG
|
||||||
|
- INFO
|
||||||
|
- WARN
|
||||||
|
- ERROR
|
||||||
|
- FATAL
|
||||||
|
|
||||||
|
#### Sending Log Messages
|
||||||
|
|
||||||
|
For synchronous servers:
|
||||||
|
```java
|
||||||
|
LoggingMessageNotification notification = McpSchema.LoggingMessageNotification.builder()
|
||||||
|
.level(McpSchema.LoggingLevel.INFO)
|
||||||
|
.logger("my-logger")
|
||||||
|
.data("Log message")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
mcpSyncServer.loggingNotification(notification);
|
||||||
|
```
|
||||||
|
|
||||||
|
For asynchronous servers:
|
||||||
|
```java
|
||||||
|
LoggingMessageNotification notification = McpSchema.LoggingMessageNotification.builder()
|
||||||
|
.level(McpSchema.LoggingLevel.INFO)
|
||||||
|
.logger("my-logger")
|
||||||
|
.data("Log message")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
mcpAsyncServer.loggingNotification(notification)
|
||||||
|
.subscribe();
|
||||||
|
```
|
||||||
|
|
||||||
|
- Logging works even if the logging capability is not enabled
|
||||||
|
- Null notifications are rejected with a McpError
|
||||||
|
- Log messages include:
|
||||||
|
- Level: The severity level of the message
|
||||||
|
- Logger: The name of the logger (typically identifies the source)
|
||||||
|
- Data: The actual log message content
|
||||||
|
- The server implementation is transport-agnostic, allowing different transport mechanisms to handle the log messages
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,63 @@ McpTransport transport = new SseClientTransport(webClientBuilder);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### Client Capabilities
|
||||||
|
|
||||||
|
The client can be configured with various capabilities:
|
||||||
|
|
||||||
|
```java
|
||||||
|
var capabilities = ClientCapabilities.builder()
|
||||||
|
.roots(true) // Enable filesystem roots support with list changes notifications
|
||||||
|
.sampling() // Enable LLM sampling support
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Roots Support
|
||||||
|
|
||||||
|
Roots define the boundaries of where servers can operate within the filesystem:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Add a root dynamically
|
||||||
|
client.addRoot(new Root("file:///path", "description"));
|
||||||
|
|
||||||
|
// Remove a root
|
||||||
|
client.removeRoot("file:///path");
|
||||||
|
|
||||||
|
// Notify server of roots changes
|
||||||
|
client.rootsListChangedNotification();
|
||||||
|
```
|
||||||
|
|
||||||
|
The roots capability allows servers to:
|
||||||
|
- Request the list of accessible filesystem roots
|
||||||
|
- Receive notifications when the root list changes
|
||||||
|
- Understand which directories and files they have access to
|
||||||
|
|
||||||
|
#### Sampling Support
|
||||||
|
|
||||||
|
Sampling enables servers to request LLM interactions ("completions" or "generations") through the client:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Configure sampling handler
|
||||||
|
Function<CreateMessageRequest, CreateMessageResult> samplingHandler = request -> {
|
||||||
|
// Sampling implementation that interfaces with LLM
|
||||||
|
return new CreateMessageResult(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create client with sampling support
|
||||||
|
var client = McpClient.using(transport)
|
||||||
|
.capabilities(ClientCapabilities.builder()
|
||||||
|
.sampling()
|
||||||
|
.build())
|
||||||
|
.samplingHandler(samplingHandler)
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
This capability allows:
|
||||||
|
- Servers to leverage AI capabilities without requiring API keys
|
||||||
|
- Clients to maintain control over model access and permissions
|
||||||
|
- Support for both text and image-based interactions
|
||||||
|
- Optional inclusion of MCP server context in prompts
|
||||||
|
|
||||||
## Server Usage Examples
|
## Server Usage Examples
|
||||||
|
|
||||||
### MCP Server (Sync API)
|
### MCP Server (Sync API)
|
||||||
|
|
@ -307,10 +364,36 @@ var capabilities = ServerCapabilities.builder()
|
||||||
.resources(false, true) // Resource support with list changes notifications
|
.resources(false, true) // Resource support with list changes notifications
|
||||||
.tools(true) // Tool support with list changes notifications
|
.tools(true) // Tool support with list changes notifications
|
||||||
.prompts(true) // Prompt support with list changes notifications
|
.prompts(true) // Prompt support with list changes notifications
|
||||||
|
.logging() // Enable logging support (enabled by default with loging level INFO)
|
||||||
.build();
|
.build();
|
||||||
```
|
```
|
||||||
|
|
||||||
### Tool Registration
|
#### Logging Support
|
||||||
|
|
||||||
|
The server provides structured logging capabilities that allow sending log messages to clients with different severity levels:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Send a log message to clients
|
||||||
|
server.loggingNotification(LoggingMessageNotification.builder()
|
||||||
|
.level(LoggingLevel.INFO)
|
||||||
|
.logger("custom-logger")
|
||||||
|
.data("Custom log message")
|
||||||
|
.build());
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported logging levels (in order of increasing severity):
|
||||||
|
- DEBUG (0)
|
||||||
|
- INFO (1)
|
||||||
|
- NOTICE (2)
|
||||||
|
- WARNING (3)
|
||||||
|
- ERROR (4)
|
||||||
|
- CRITICAL (5)
|
||||||
|
- ALERT (6)
|
||||||
|
- EMERGENCY (7)
|
||||||
|
|
||||||
|
Clients can control the minimum logging level they receive through the `mcpClient.setLoggingLevel(level)` request. Messages below the set level will be filtered out.
|
||||||
|
|
||||||
|
#### Tool Registration
|
||||||
|
|
||||||
```java
|
```java
|
||||||
var toolRegistration = new ToolRegistration(
|
var toolRegistration = new ToolRegistration(
|
||||||
|
|
@ -326,7 +409,7 @@ var toolRegistration = new ToolRegistration(
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Resource Registration
|
#### Resource Registration
|
||||||
|
|
||||||
```java
|
```java
|
||||||
var resourceRegistration = new ResourceRegistration(
|
var resourceRegistration = new ResourceRegistration(
|
||||||
|
|
@ -338,7 +421,7 @@ var resourceRegistration = new ResourceRegistration(
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Prompt Registration
|
#### Prompt Registration
|
||||||
|
|
||||||
```java
|
```java
|
||||||
var promptRegistration = new PromptRegistration(
|
var promptRegistration = new PromptRegistration(
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ import org.springframework.ai.mcp.spec.McpSchema.GetPromptRequest;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.GetPromptResult;
|
import org.springframework.ai.mcp.spec.McpSchema.GetPromptResult;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.Implementation;
|
import org.springframework.ai.mcp.spec.McpSchema.Implementation;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.ListPromptsResult;
|
import org.springframework.ai.mcp.spec.McpSchema.ListPromptsResult;
|
||||||
|
import org.springframework.ai.mcp.spec.McpSchema.LoggingLevel;
|
||||||
|
import org.springframework.ai.mcp.spec.McpSchema.LoggingMessageNotification;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.PaginatedRequest;
|
import org.springframework.ai.mcp.spec.McpSchema.PaginatedRequest;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.Root;
|
import org.springframework.ai.mcp.spec.McpSchema.Root;
|
||||||
import org.springframework.ai.mcp.spec.McpTransport;
|
import org.springframework.ai.mcp.spec.McpTransport;
|
||||||
|
|
@ -114,6 +116,7 @@ public class McpAsyncClient {
|
||||||
* @param toolsChangeConsumers the tools change consumers.
|
* @param toolsChangeConsumers the tools change consumers.
|
||||||
* @param resourcesChangeConsumers the resources change consumers.
|
* @param resourcesChangeConsumers the resources change consumers.
|
||||||
* @param promptsChangeConsumers the prompts change consumers.
|
* @param promptsChangeConsumers the prompts change consumers.
|
||||||
|
* @param loggingConsumers the logging consumers.
|
||||||
* @param samplingHandler the sampling handler.
|
* @param samplingHandler the sampling handler.
|
||||||
*/
|
*/
|
||||||
public McpAsyncClient(McpTransport transport, Duration requestTimeout, Implementation clientInfo,
|
public McpAsyncClient(McpTransport transport, Duration requestTimeout, Implementation clientInfo,
|
||||||
|
|
@ -121,6 +124,7 @@ public class McpAsyncClient {
|
||||||
List<Consumer<List<McpSchema.Tool>>> toolsChangeConsumers,
|
List<Consumer<List<McpSchema.Tool>>> toolsChangeConsumers,
|
||||||
List<Consumer<List<McpSchema.Resource>>> resourcesChangeConsumers,
|
List<Consumer<List<McpSchema.Resource>>> resourcesChangeConsumers,
|
||||||
List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers,
|
List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers,
|
||||||
|
List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers,
|
||||||
Function<CreateMessageRequest, CreateMessageResult> samplingHandler) {
|
Function<CreateMessageRequest, CreateMessageResult> samplingHandler) {
|
||||||
|
|
||||||
Assert.notNull(transport, "Transport must not be null");
|
Assert.notNull(transport, "Transport must not be null");
|
||||||
|
|
@ -142,13 +146,13 @@ public class McpAsyncClient {
|
||||||
|
|
||||||
// Roots List Request Handler
|
// Roots List Request Handler
|
||||||
if (!Utils.isEmpty(this.roots) && this.clientCapabilities.roots() != null) {
|
if (!Utils.isEmpty(this.roots) && this.clientCapabilities.roots() != null) {
|
||||||
requestHanlers.put("roots/list", rootsListRequestHandler());
|
requestHanlers.put(McpSchema.METHOD_ROOTS_LIST, rootsListRequestHandler());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sampling Handler
|
// Sampling Handler
|
||||||
if (samplingHandler != null && this.clientCapabilities.sampling() != null) {
|
if (samplingHandler != null && this.clientCapabilities.sampling() != null) {
|
||||||
this.samplingHandler = samplingHandler;
|
this.samplingHandler = samplingHandler;
|
||||||
requestHanlers.put("sampling/createMessage", samplingCreateMessageHandler());
|
requestHanlers.put(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, samplingCreateMessageHandler());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notification Handlers
|
// Notification Handlers
|
||||||
|
|
@ -160,7 +164,7 @@ public class McpAsyncClient {
|
||||||
if (!Utils.isEmpty(toolsChangeConsumers)) {
|
if (!Utils.isEmpty(toolsChangeConsumers)) {
|
||||||
toolsChangeConsumersFinal.addAll(toolsChangeConsumers);
|
toolsChangeConsumersFinal.addAll(toolsChangeConsumers);
|
||||||
}
|
}
|
||||||
notificationHandlers.put("notifications/tools/list_changed",
|
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED,
|
||||||
toolsChangeNotificationHandler(toolsChangeConsumersFinal));
|
toolsChangeNotificationHandler(toolsChangeConsumersFinal));
|
||||||
|
|
||||||
// Resources Change Notification
|
// Resources Change Notification
|
||||||
|
|
@ -169,7 +173,7 @@ public class McpAsyncClient {
|
||||||
if (!Utils.isEmpty(resourcesChangeConsumers)) {
|
if (!Utils.isEmpty(resourcesChangeConsumers)) {
|
||||||
resourcesChangeConsumersFinal.addAll(resourcesChangeConsumers);
|
resourcesChangeConsumersFinal.addAll(resourcesChangeConsumers);
|
||||||
}
|
}
|
||||||
notificationHandlers.put("notifications/resources/list_changed",
|
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED,
|
||||||
resourcesChangeNotificationHandler(resourcesChangeConsumersFinal));
|
resourcesChangeNotificationHandler(resourcesChangeConsumersFinal));
|
||||||
|
|
||||||
// Prompts Change Notification
|
// Prompts Change Notification
|
||||||
|
|
@ -178,9 +182,18 @@ public class McpAsyncClient {
|
||||||
if (!Utils.isEmpty(promptsChangeConsumers)) {
|
if (!Utils.isEmpty(promptsChangeConsumers)) {
|
||||||
promptsChangeConsumersFinal.addAll(promptsChangeConsumers);
|
promptsChangeConsumersFinal.addAll(promptsChangeConsumers);
|
||||||
}
|
}
|
||||||
notificationHandlers.put("notifications/prompts/list_changed",
|
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED,
|
||||||
promptsChangeNotificationHandler(promptsChangeConsumersFinal));
|
promptsChangeNotificationHandler(promptsChangeConsumersFinal));
|
||||||
|
|
||||||
|
// Utility Logging Notification
|
||||||
|
List<Consumer<LoggingMessageNotification>> loggingConsumersFinal = new ArrayList<>();
|
||||||
|
loggingConsumersFinal.add((notification) -> logger.info("Logging: {}", notification));
|
||||||
|
if (!Utils.isEmpty(loggingConsumers)) {
|
||||||
|
loggingConsumersFinal.addAll(loggingConsumers);
|
||||||
|
}
|
||||||
|
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_MESSAGE,
|
||||||
|
loggingNotificationHandler(loggingConsumersFinal));
|
||||||
|
|
||||||
this.mcpSession = new DefaultMcpSession(requestTimeout, transport, requestHanlers, notificationHandlers);
|
this.mcpSession = new DefaultMcpSession(requestTimeout, transport, requestHanlers, notificationHandlers);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -222,8 +235,8 @@ public class McpAsyncClient {
|
||||||
this.clientCapabilities,
|
this.clientCapabilities,
|
||||||
this.clientInfo); // @formatter:on
|
this.clientInfo); // @formatter:on
|
||||||
|
|
||||||
Mono<McpSchema.InitializeResult> result = this.mcpSession.sendRequest("initialize", initializeRequest,
|
Mono<McpSchema.InitializeResult> result = this.mcpSession.sendRequest(McpSchema.METHOD_INITIALIZE,
|
||||||
new TypeReference<McpSchema.InitializeResult>() {
|
initializeRequest, new TypeReference<McpSchema.InitializeResult>() {
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.flatMap(initializeResult -> {
|
return result.flatMap(initializeResult -> {
|
||||||
|
|
@ -237,7 +250,8 @@ public class McpAsyncClient {
|
||||||
"Unsupported protocol version from the server: " + initializeResult.protocolVersion()));
|
"Unsupported protocol version from the server: " + initializeResult.protocolVersion()));
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return this.mcpSession.sendNotification("notifications/initialized", null).thenReturn(initializeResult);
|
return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_INITIALIZED, null)
|
||||||
|
.thenReturn(initializeResult);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -266,7 +280,7 @@ public class McpAsyncClient {
|
||||||
* @return A Mono that completes with the server's ping response
|
* @return A Mono that completes with the server's ping response
|
||||||
*/
|
*/
|
||||||
public Mono<Object> ping() {
|
public Mono<Object> ping() {
|
||||||
return this.mcpSession.sendRequest("ping", null, new TypeReference<Object>() {
|
return this.mcpSession.sendRequest(McpSchema.METHOD_PING, null, new TypeReference<Object>() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -335,7 +349,7 @@ public class McpAsyncClient {
|
||||||
* @return A Mono that completes when the notification is sent
|
* @return A Mono that completes when the notification is sent
|
||||||
*/
|
*/
|
||||||
public Mono<Void> rootsListChangedNotification() {
|
public Mono<Void> rootsListChangedNotification() {
|
||||||
return this.mcpSession.sendNotification("notifications/roots/list_changed");
|
return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED);
|
||||||
}
|
}
|
||||||
|
|
||||||
private RequestHandler rootsListRequestHandler() {
|
private RequestHandler rootsListRequestHandler() {
|
||||||
|
|
@ -387,7 +401,7 @@ public class McpAsyncClient {
|
||||||
* (false/absent)
|
* (false/absent)
|
||||||
*/
|
*/
|
||||||
public Mono<McpSchema.CallToolResult> callTool(McpSchema.CallToolRequest callToolRequest) {
|
public Mono<McpSchema.CallToolResult> callTool(McpSchema.CallToolRequest callToolRequest) {
|
||||||
return this.mcpSession.sendRequest("tools/call", callToolRequest, CALL_TOOL_RESULT_TYPE_REF);
|
return this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_CALL, callToolRequest, CALL_TOOL_RESULT_TYPE_REF);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -408,7 +422,7 @@ public class McpAsyncClient {
|
||||||
* Optional cursor for pagination if more tools are available
|
* Optional cursor for pagination if more tools are available
|
||||||
*/
|
*/
|
||||||
public Mono<McpSchema.ListToolsResult> listTools(String cursor) {
|
public Mono<McpSchema.ListToolsResult> listTools(String cursor) {
|
||||||
return this.mcpSession.sendRequest("tools/list", new McpSchema.PaginatedRequest(cursor),
|
return this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor),
|
||||||
LIST_TOOLS_RESULT_TYPE_REF);
|
LIST_TOOLS_RESULT_TYPE_REF);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -427,20 +441,14 @@ public class McpAsyncClient {
|
||||||
private NotificationHandler toolsChangeNotificationHandler(
|
private NotificationHandler toolsChangeNotificationHandler(
|
||||||
List<Consumer<List<McpSchema.Tool>>> toolsChangeConsumers) {
|
List<Consumer<List<McpSchema.Tool>>> toolsChangeConsumers) {
|
||||||
|
|
||||||
return new NotificationHandler() {
|
return params -> listTools().flatMap(listToolsResult -> Mono.fromRunnable(() -> {
|
||||||
@Override
|
for (Consumer<List<McpSchema.Tool>> toolsChangeConsumer : toolsChangeConsumers) {
|
||||||
public Mono<Void> handle(Object params) {
|
toolsChangeConsumer.accept(listToolsResult.tools());
|
||||||
// TODO: add support for cursor/pagination
|
|
||||||
return listTools().flatMap(listToolsResult -> Mono.fromRunnable(() -> {
|
|
||||||
for (Consumer<List<McpSchema.Tool>> toolsChangeConsumer : toolsChangeConsumers) {
|
|
||||||
toolsChangeConsumer.accept(listToolsResult.tools());
|
|
||||||
}
|
|
||||||
}).subscribeOn(Schedulers.boundedElastic())).onErrorResume(error -> {
|
|
||||||
logger.error("Error handling tools list change notification", error);
|
|
||||||
return Mono.empty();
|
|
||||||
}).then(); // Convert to Mono<Void>
|
|
||||||
}
|
}
|
||||||
};
|
}).subscribeOn(Schedulers.boundedElastic())).onErrorResume(error -> {
|
||||||
|
logger.error("Error handling tools list change notification", error);
|
||||||
|
return Mono.empty();
|
||||||
|
}).then(); // Convert to Mono<Void>
|
||||||
};
|
};
|
||||||
|
|
||||||
// --------------------------
|
// --------------------------
|
||||||
|
|
@ -470,7 +478,7 @@ public class McpAsyncClient {
|
||||||
* @return A Mono that completes with the list of resources result
|
* @return A Mono that completes with the list of resources result
|
||||||
*/
|
*/
|
||||||
public Mono<McpSchema.ListResourcesResult> listResources(String cursor) {
|
public Mono<McpSchema.ListResourcesResult> listResources(String cursor) {
|
||||||
return this.mcpSession.sendRequest("resources/list", new McpSchema.PaginatedRequest(cursor),
|
return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor),
|
||||||
LIST_RESOURCES_RESULT_TYPE_REF);
|
LIST_RESOURCES_RESULT_TYPE_REF);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -489,7 +497,8 @@ public class McpAsyncClient {
|
||||||
* @return A Mono that completes with the resource content
|
* @return A Mono that completes with the resource content
|
||||||
*/
|
*/
|
||||||
public Mono<McpSchema.ReadResourceResult> readResource(McpSchema.ReadResourceRequest readResourceRequest) {
|
public Mono<McpSchema.ReadResourceResult> readResource(McpSchema.ReadResourceRequest readResourceRequest) {
|
||||||
return this.mcpSession.sendRequest("resources/read", readResourceRequest, READ_RESOURCE_RESULT_TYPE_REF);
|
return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_READ, readResourceRequest,
|
||||||
|
READ_RESOURCE_RESULT_TYPE_REF);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -512,8 +521,8 @@ public class McpAsyncClient {
|
||||||
* @return A Mono that completes with the list of resource templates result
|
* @return A Mono that completes with the list of resource templates result
|
||||||
*/
|
*/
|
||||||
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates(String cursor) {
|
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates(String cursor) {
|
||||||
return this.mcpSession.sendRequest("resources/templates/list", new McpSchema.PaginatedRequest(cursor),
|
return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST,
|
||||||
LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF);
|
new McpSchema.PaginatedRequest(cursor), LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -522,7 +531,7 @@ public class McpAsyncClient {
|
||||||
* @return A Mono that completes when the notification is sent
|
* @return A Mono that completes when the notification is sent
|
||||||
*/
|
*/
|
||||||
public Mono<Void> sendResourcesListChanged() {
|
public Mono<Void> sendResourcesListChanged() {
|
||||||
return this.mcpSession.sendNotification("notifications/resources/list_changed");
|
return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -536,7 +545,7 @@ public class McpAsyncClient {
|
||||||
* @return A Mono that completes when the subscription is complete
|
* @return A Mono that completes when the subscription is complete
|
||||||
*/
|
*/
|
||||||
public Mono<Void> subscribeResource(McpSchema.SubscribeRequest subscribeRequest) {
|
public Mono<Void> subscribeResource(McpSchema.SubscribeRequest subscribeRequest) {
|
||||||
return this.mcpSession.sendRequest("resources/subscribe", subscribeRequest, VOID_TYPE_REFERENCE);
|
return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_SUBSCRIBE, subscribeRequest, VOID_TYPE_REFERENCE);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -546,26 +555,21 @@ public class McpAsyncClient {
|
||||||
* @return A Mono that completes when the unsubscription is complete
|
* @return A Mono that completes when the unsubscription is complete
|
||||||
*/
|
*/
|
||||||
public Mono<Void> unsubscribeResource(McpSchema.UnsubscribeRequest unsubscribeRequest) {
|
public Mono<Void> unsubscribeResource(McpSchema.UnsubscribeRequest unsubscribeRequest) {
|
||||||
return this.mcpSession.sendRequest("resources/unsubscribe", unsubscribeRequest, VOID_TYPE_REFERENCE);
|
return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_UNSUBSCRIBE, unsubscribeRequest,
|
||||||
|
VOID_TYPE_REFERENCE);
|
||||||
}
|
}
|
||||||
|
|
||||||
private NotificationHandler resourcesChangeNotificationHandler(
|
private NotificationHandler resourcesChangeNotificationHandler(
|
||||||
List<Consumer<List<McpSchema.Resource>>> resourcesChangeConsumers) {
|
List<Consumer<List<McpSchema.Resource>>> resourcesChangeConsumers) {
|
||||||
|
|
||||||
return new NotificationHandler() {
|
return params -> listResources().flatMap(listResourcesResult -> Mono.fromRunnable(() -> {
|
||||||
@Override
|
for (Consumer<List<McpSchema.Resource>> resourceChangeConsumer : resourcesChangeConsumers) {
|
||||||
public Mono<Void> handle(Object params) {
|
resourceChangeConsumer.accept(listResourcesResult.resources());
|
||||||
// TODO: add support for cursor/pagination
|
|
||||||
return listResources().flatMap(listResourcesResult -> Mono.fromRunnable(() -> {
|
|
||||||
for (Consumer<List<McpSchema.Resource>> resourceChangeConsumer : resourcesChangeConsumers) {
|
|
||||||
resourceChangeConsumer.accept(listResourcesResult.resources());
|
|
||||||
}
|
|
||||||
}).subscribeOn(Schedulers.boundedElastic())).onErrorResume(error -> {
|
|
||||||
logger.error("Error handling resources list change notification", error);
|
|
||||||
return Mono.empty();
|
|
||||||
}).then(); // Convert to Mono<Void>
|
|
||||||
}
|
}
|
||||||
};
|
}).subscribeOn(Schedulers.boundedElastic())).onErrorResume(error -> {
|
||||||
|
logger.error("Error handling resources list change notification", error);
|
||||||
|
return Mono.empty();
|
||||||
|
}).then();
|
||||||
};
|
};
|
||||||
|
|
||||||
// --------------------------
|
// --------------------------
|
||||||
|
|
@ -591,7 +595,8 @@ public class McpAsyncClient {
|
||||||
* @return A Mono that completes with the list of prompts result
|
* @return A Mono that completes with the list of prompts result
|
||||||
*/
|
*/
|
||||||
public Mono<ListPromptsResult> listPrompts(String cursor) {
|
public Mono<ListPromptsResult> listPrompts(String cursor) {
|
||||||
return this.mcpSession.sendRequest("prompts/list", new PaginatedRequest(cursor), LIST_PROMPTS_RESULT_TYPE_REF);
|
return this.mcpSession.sendRequest(McpSchema.METHOD_PROMPT_LIST, new PaginatedRequest(cursor),
|
||||||
|
LIST_PROMPTS_RESULT_TYPE_REF);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -600,7 +605,7 @@ public class McpAsyncClient {
|
||||||
* @return A Mono that completes with the get prompt result
|
* @return A Mono that completes with the get prompt result
|
||||||
*/
|
*/
|
||||||
public Mono<GetPromptResult> getPrompt(GetPromptRequest getPromptRequest) {
|
public Mono<GetPromptResult> getPrompt(GetPromptRequest getPromptRequest) {
|
||||||
return this.mcpSession.sendRequest("prompts/get", getPromptRequest, GET_PROMPT_RESULT_TYPE_REF);
|
return this.mcpSession.sendRequest(McpSchema.METHOD_PROMPT_GET, getPromptRequest, GET_PROMPT_RESULT_TYPE_REF);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -610,26 +615,66 @@ public class McpAsyncClient {
|
||||||
* @return A Mono that completes when the notification is sent
|
* @return A Mono that completes when the notification is sent
|
||||||
*/
|
*/
|
||||||
public Mono<Void> promptListChangedNotification() {
|
public Mono<Void> promptListChangedNotification() {
|
||||||
return this.mcpSession.sendNotification("notifications/prompts/list_changed");
|
return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED);
|
||||||
}
|
}
|
||||||
|
|
||||||
private NotificationHandler promptsChangeNotificationHandler(
|
private NotificationHandler promptsChangeNotificationHandler(
|
||||||
List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers) {
|
List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers) {
|
||||||
|
|
||||||
return new NotificationHandler() {
|
return params -> {// @formatter:off
|
||||||
@Override
|
return listPrompts().flatMap(listPromptsResult -> Mono.fromRunnable(() -> {
|
||||||
public Mono<Void> handle(Object params) {
|
for (Consumer<List<McpSchema.Prompt>> promptChangeConsumer : promptsChangeConsumers) {
|
||||||
// TODO: add support for cursor/pagination
|
promptChangeConsumer.accept(listPromptsResult.prompts());
|
||||||
return listPrompts().flatMap(listPromptsResult -> Mono.fromRunnable(() -> {
|
}
|
||||||
for (Consumer<List<McpSchema.Prompt>> promptChangeConsumer : promptsChangeConsumers) {
|
}).subscribeOn(Schedulers.boundedElastic())).onErrorResume(error -> {
|
||||||
promptChangeConsumer.accept(listPromptsResult.prompts());
|
logger.error("Error handling prompts list change notification", error);
|
||||||
}
|
return Mono.empty();
|
||||||
}).subscribeOn(Schedulers.boundedElastic())).onErrorResume(error -> {
|
}).then(); // Convert to Mono<Void>
|
||||||
logger.error("Error handling prompts list change notification", error);
|
}; // @formatter:on
|
||||||
return Mono.empty();
|
|
||||||
}).then(); // Convert to Mono<Void>
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --------------------------
|
||||||
|
// Logging
|
||||||
|
// --------------------------
|
||||||
|
/**
|
||||||
|
* 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 loggingNotificationHandler(
|
||||||
|
List<Consumer<LoggingMessageNotification>> loggingConsumers) {
|
||||||
|
|
||||||
|
return params -> {
|
||||||
|
|
||||||
|
McpSchema.LoggingMessageNotification loggingMessageNotification = transport.unmarshalFrom(params,
|
||||||
|
new TypeReference<McpSchema.LoggingMessageNotification>() {
|
||||||
|
});
|
||||||
|
|
||||||
|
return Mono.fromRunnable(() -> {
|
||||||
|
for (Consumer<LoggingMessageNotification> loggingConsumer : loggingConsumers) {
|
||||||
|
loggingConsumer.accept(loggingMessageNotification);
|
||||||
|
}
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic()).then();
|
||||||
|
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client can set the minimum logging level it wants to receive from the server.
|
||||||
|
* @param loggingLevel the min logging level
|
||||||
|
*/
|
||||||
|
public Mono<Void> setLoggingLevel(LoggingLevel loggingLevel) {
|
||||||
|
Assert.notNull(loggingLevel, "Logging level must not be null");
|
||||||
|
|
||||||
|
String levelName = this.transport.unmarshalFrom(loggingLevel, new TypeReference<String>() {
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, Object> params = Map.of("level", levelName);
|
||||||
|
|
||||||
|
return this.mcpSession.sendNotification(McpSchema.METHOD_LOGGING_SET_LEVEL, params);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,9 @@ import org.springframework.ai.mcp.util.Assert;
|
||||||
* .clientInfo(new Implementation("My Client", "1.0.0"))
|
* .clientInfo(new Implementation("My Client", "1.0.0"))
|
||||||
* .roots(new Root("file://workspace", "Workspace Files"))
|
* .roots(new Root("file://workspace", "Workspace Files"))
|
||||||
* .toolsChangeConsumer(tools -> System.out.println("Tools updated: " + tools))
|
* .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();
|
* .async();
|
||||||
* }</pre>
|
* }</pre>
|
||||||
*
|
*
|
||||||
|
|
@ -82,6 +85,16 @@ import org.springframework.ai.mcp.util.Assert;
|
||||||
* <li>Prompt template handling
|
* <li>Prompt template handling
|
||||||
* <li>Real-time updates through change consumers
|
* <li>Real-time updates through change consumers
|
||||||
* <li>Custom sampling strategies
|
* <li>Custom sampling strategies
|
||||||
|
* <li>Structured logging with severity levels
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The client supports structured logging through the MCP logging utility:
|
||||||
|
* <ul>
|
||||||
|
* <li>Eight severity levels from DEBUG to EMERGENCY
|
||||||
|
* <li>Optional logger name categorization
|
||||||
|
* <li>Configurable logging consumers
|
||||||
|
* <li>Server-controlled minimum log level
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* @author Christian Tzolov
|
* @author Christian Tzolov
|
||||||
|
|
@ -141,6 +154,8 @@ public interface McpClient {
|
||||||
|
|
||||||
private List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers = 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 Function<CreateMessageRequest, CreateMessageResult> samplingHandler;
|
||||||
|
|
||||||
private Builder(McpTransport transport) {
|
private Builder(McpTransport transport) {
|
||||||
|
|
@ -284,6 +299,34 @@ public interface McpClient {
|
||||||
return this;
|
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 Builder 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 Builder loggingConsumers(List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers) {
|
||||||
|
Assert.notNull(loggingConsumers, "Logging consumers must not be null");
|
||||||
|
this.loggingConsumers.addAll(loggingConsumers);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a synchronous MCP client that provides blocking operations. Synchronous
|
* Builds a synchronous MCP client that provides blocking operations. Synchronous
|
||||||
* clients wait for each operation to complete before returning, making them
|
* clients wait for each operation to complete before returning, making them
|
||||||
|
|
@ -304,7 +347,7 @@ public interface McpClient {
|
||||||
*/
|
*/
|
||||||
public McpAsyncClient async() {
|
public McpAsyncClient async() {
|
||||||
return new McpAsyncClient(transport, requestTimeout, clientInfo, capabilities, roots, toolsChangeConsumers,
|
return new McpAsyncClient(transport, requestTimeout, clientInfo, capabilities, roots, toolsChangeConsumers,
|
||||||
resourcesChangeConsumers, promptsChangeConsumers, samplingHandler);
|
resourcesChangeConsumers, promptsChangeConsumers, loggingConsumers, samplingHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -280,4 +280,12 @@ public class McpSyncClient implements AutoCloseable {
|
||||||
this.delegate.promptListChangedNotification().block();
|
this.delegate.promptListChangedNotification().block();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client can set the minimum logging level it wants to receive from the server.
|
||||||
|
* @param loggingLevel the min logging level
|
||||||
|
*/
|
||||||
|
public void setLoggingLevel(McpSchema.LoggingLevel loggingLevel) {
|
||||||
|
this.delegate.setLoggingLevel(loggingLevel).block();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,19 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024-2024 the original author or authors.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
package org.springframework.ai.mcp.server;
|
package org.springframework.ai.mcp.server;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
|
@ -22,6 +38,8 @@ import org.springframework.ai.mcp.spec.DefaultMcpSession.NotificationHandler;
|
||||||
import org.springframework.ai.mcp.spec.McpError;
|
import org.springframework.ai.mcp.spec.McpError;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema;
|
import org.springframework.ai.mcp.spec.McpSchema;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
|
import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
|
||||||
|
import org.springframework.ai.mcp.spec.McpSchema.LoggingLevel;
|
||||||
|
import org.springframework.ai.mcp.spec.McpSchema.LoggingMessageNotification;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.Tool;
|
import org.springframework.ai.mcp.spec.McpSchema.Tool;
|
||||||
import org.springframework.ai.mcp.spec.McpTransport;
|
import org.springframework.ai.mcp.spec.McpTransport;
|
||||||
import org.springframework.ai.mcp.util.Utils;
|
import org.springframework.ai.mcp.util.Utils;
|
||||||
|
|
@ -59,6 +77,9 @@ public class McpAsyncServer {
|
||||||
|
|
||||||
private final ConcurrentHashMap<String, PromptRegistration> prompts;
|
private final ConcurrentHashMap<String, PromptRegistration> prompts;
|
||||||
|
|
||||||
|
private LoggingLevel minLoggingLevel = LoggingLevel.DEBUG;
|
||||||
|
|
||||||
|
// TODO: Add support for roots list changed notification
|
||||||
/**
|
/**
|
||||||
* Create a new McpAsyncServer with the given transport and capabilities.
|
* Create a new McpAsyncServer with the given transport and capabilities.
|
||||||
* @param mcpTransport The transport layer implementation for MCP communication
|
* @param mcpTransport The transport layer implementation for MCP communication
|
||||||
|
|
@ -83,7 +104,8 @@ public class McpAsyncServer {
|
||||||
|
|
||||||
this.serverCapabilities = (serverCapabilities != null) ? serverCapabilities : new McpSchema.ServerCapabilities(
|
this.serverCapabilities = (serverCapabilities != null) ? serverCapabilities : new McpSchema.ServerCapabilities(
|
||||||
null, // experimental
|
null, // experimental
|
||||||
null, // logging
|
new McpSchema.ServerCapabilities.LoggingCapabilities(), // Enable logging
|
||||||
|
// by default
|
||||||
!Utils.isEmpty(this.prompts) ? new McpSchema.ServerCapabilities.PromptCapabilities(false) : null,
|
!Utils.isEmpty(this.prompts) ? new McpSchema.ServerCapabilities.PromptCapabilities(false) : null,
|
||||||
!Utils.isEmpty(this.resources) ? new McpSchema.ServerCapabilities.ResourceCapabilities(false, false)
|
!Utils.isEmpty(this.resources) ? new McpSchema.ServerCapabilities.ResourceCapabilities(false, false)
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -92,37 +114,42 @@ public class McpAsyncServer {
|
||||||
Map<String, DefaultMcpSession.RequestHandler> requestHandlers = new HashMap<>();
|
Map<String, DefaultMcpSession.RequestHandler> requestHandlers = new HashMap<>();
|
||||||
|
|
||||||
// Initialize request handlers for standard MCP methods
|
// Initialize request handlers for standard MCP methods
|
||||||
requestHandlers.put("initialize", initializeRequestHandler());
|
requestHandlers.put(McpSchema.METHOD_INITIALIZE, initializeRequestHandler());
|
||||||
|
|
||||||
// Ping MUST respond with an empty data, but not NULL response.
|
// Ping MUST respond with an empty data, but not NULL response.
|
||||||
requestHandlers.put("ping", (params) -> Mono.<Object>just(""));
|
requestHandlers.put(McpSchema.METHOD_PING, (params) -> Mono.<Object>just(""));
|
||||||
|
|
||||||
// Add tools API handlers if the tool capability is enabled
|
// Add tools API handlers if the tool capability is enabled
|
||||||
if (this.serverCapabilities.tools() != null) {
|
if (this.serverCapabilities.tools() != null) {
|
||||||
requestHandlers.put("tools/list", toolsListRequestHandler());
|
requestHandlers.put(McpSchema.METHOD_TOOLS_LIST, toolsListRequestHandler());
|
||||||
requestHandlers.put("tools/call", toolsCallRequestHandler());
|
requestHandlers.put(McpSchema.METHOD_TOOLS_CALL, toolsCallRequestHandler());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add resources API handlers if provided
|
// Add resources API handlers if provided
|
||||||
if (!Utils.isEmpty(this.resources)) {
|
if (!Utils.isEmpty(this.resources)) {
|
||||||
requestHandlers.put("resources/list", resourcesListRequestHandler());
|
requestHandlers.put(McpSchema.METHOD_RESOURCES_LIST, resourcesListRequestHandler());
|
||||||
requestHandlers.put("resources/read", resourcesReadRequestHandler());
|
requestHandlers.put(McpSchema.METHOD_RESOURCES_READ, resourcesReadRequestHandler());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add resource templates API handlers if provided.
|
// Add resource templates API handlers if provided.
|
||||||
if (!Utils.isEmpty(this.resourceTemplates)) {
|
if (!Utils.isEmpty(this.resourceTemplates)) {
|
||||||
requestHandlers.put("resources/templates/list", resourceTemplateListRequestHandler());
|
requestHandlers.put(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, resourceTemplateListRequestHandler());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add prompts API handlers if provider exists
|
// Add prompts API handlers if provider exists
|
||||||
if (!Utils.isEmpty(this.prompts)) {
|
if (!Utils.isEmpty(this.prompts)) {
|
||||||
requestHandlers.put("prompts/list", promptsListRequestHandler());
|
requestHandlers.put(McpSchema.METHOD_PROMPT_LIST, promptsListRequestHandler());
|
||||||
requestHandlers.put("prompts/get", promptsGetRequestHandler());
|
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<>();
|
Map<String, NotificationHandler> notificationHandlers = new HashMap<>();
|
||||||
|
|
||||||
notificationHandlers.put("notifications/initialized", (params) -> Mono.empty());
|
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_INITIALIZED, (params) -> Mono.empty());
|
||||||
|
|
||||||
this.transport = mcpTransport;
|
this.transport = mcpTransport;
|
||||||
this.mcpSession = new DefaultMcpSession(Duration.ofSeconds(10), mcpTransport, requestHandlers,
|
this.mcpSession = new DefaultMcpSession(Duration.ofSeconds(10), mcpTransport, requestHandlers,
|
||||||
|
|
@ -236,20 +263,16 @@ public class McpAsyncServer {
|
||||||
* @return A Mono that completes when all clients have been notified
|
* @return A Mono that completes when all clients have been notified
|
||||||
*/
|
*/
|
||||||
public Mono<Void> notifyToolsListChanged() {
|
public Mono<Void> notifyToolsListChanged() {
|
||||||
return this.mcpSession.sendNotification("notifications/tools/list_changed", null);
|
return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private DefaultMcpSession.RequestHandler toolsListRequestHandler() {
|
private DefaultMcpSession.RequestHandler toolsListRequestHandler() {
|
||||||
return params -> {
|
return params -> {
|
||||||
McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
|
|
||||||
new TypeReference<McpSchema.PaginatedRequest>() {
|
|
||||||
});
|
|
||||||
|
|
||||||
List<Tool> tools = this.tools.stream().map(toolRegistration -> {
|
List<Tool> tools = this.tools.stream().map(toolRegistration -> {
|
||||||
return toolRegistration.tool();
|
return toolRegistration.tool();
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
logger.info("Client tools list request - Cursor: {}", request.cursor());
|
|
||||||
return Mono.just(new McpSchema.ListToolsResult(tools, null));
|
return Mono.just(new McpSchema.ListToolsResult(tools, null));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -334,7 +357,7 @@ public class McpAsyncServer {
|
||||||
* @return A Mono that completes when all clients have been notified
|
* @return A Mono that completes when all clients have been notified
|
||||||
*/
|
*/
|
||||||
public Mono<Void> notifyResourcesListChanged() {
|
public Mono<Void> notifyResourcesListChanged() {
|
||||||
return this.mcpSession.sendNotification("notifications/resources/list_changed", null);
|
return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private DefaultMcpSession.RequestHandler resourcesListRequestHandler() {
|
private DefaultMcpSession.RequestHandler resourcesListRequestHandler() {
|
||||||
|
|
@ -428,14 +451,15 @@ public class McpAsyncServer {
|
||||||
* @return A Mono that completes when all clients have been notified
|
* @return A Mono that completes when all clients have been notified
|
||||||
*/
|
*/
|
||||||
public Mono<Void> notifyPromptsListChanged() {
|
public Mono<Void> notifyPromptsListChanged() {
|
||||||
return this.mcpSession.sendNotification("notifications/prompts/list_changed", null);
|
return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private DefaultMcpSession.RequestHandler promptsListRequestHandler() {
|
private DefaultMcpSession.RequestHandler promptsListRequestHandler() {
|
||||||
return params -> {
|
return params -> {
|
||||||
McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
|
// TODO: Implement pagination
|
||||||
new TypeReference<McpSchema.PaginatedRequest>() {
|
// McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
|
||||||
});
|
// new TypeReference<McpSchema.PaginatedRequest>() {
|
||||||
|
// });
|
||||||
|
|
||||||
var promptList = this.prompts.values().stream().map(PromptRegistration::propmpt).toList();
|
var promptList = this.prompts.values().stream().map(PromptRegistration::propmpt).toList();
|
||||||
|
|
||||||
|
|
@ -458,4 +482,48 @@ public class McpAsyncServer {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// Logging Management
|
||||||
|
// ---------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a logging message notification to all connected clients. Messages below the
|
||||||
|
* current minimum logging level will be filtered out.
|
||||||
|
* @param loggingMessageNotification The logging message to send
|
||||||
|
* @return A Mono that completes when the notification has been sent
|
||||||
|
*/
|
||||||
|
public Mono<Void> loggingNotification(LoggingMessageNotification loggingMessageNotification) {
|
||||||
|
|
||||||
|
if (loggingMessageNotification == null) {
|
||||||
|
return Mono.error(new McpError("Logging message must not be null"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> params = this.transport.unmarshalFrom(loggingMessageNotification,
|
||||||
|
new TypeReference<Map<String, Object>>() {
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loggingMessageNotification.level().level() < minLoggingLevel.level()) {
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_MESSAGE, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles requests to set the minimum logging level. Messages below this level will
|
||||||
|
* not be sent.
|
||||||
|
* @return A handler that processes logging level change requests
|
||||||
|
*/
|
||||||
|
private DefaultMcpSession.RequestHandler setLoggerRequestHandler() {
|
||||||
|
return params -> {
|
||||||
|
McpSchema.LoggingLevel setLoggerRequest = transport.unmarshalFrom(params,
|
||||||
|
new TypeReference<McpSchema.LoggingLevel>() {
|
||||||
|
});
|
||||||
|
|
||||||
|
this.minLoggingLevel = setLoggerRequest;
|
||||||
|
|
||||||
|
return Mono.empty();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,12 @@
|
||||||
|
|
||||||
package org.springframework.ai.mcp.server;
|
package org.springframework.ai.mcp.server;
|
||||||
|
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
import org.springframework.ai.mcp.server.McpServer.PromptRegistration;
|
import org.springframework.ai.mcp.server.McpServer.PromptRegistration;
|
||||||
import org.springframework.ai.mcp.server.McpServer.ResourceRegistration;
|
import org.springframework.ai.mcp.server.McpServer.ResourceRegistration;
|
||||||
import org.springframework.ai.mcp.server.McpServer.ToolRegistration;
|
import org.springframework.ai.mcp.server.McpServer.ToolRegistration;
|
||||||
|
import org.springframework.ai.mcp.spec.McpSchema.LoggingMessageNotification;
|
||||||
import org.springframework.ai.mcp.util.Assert;
|
import org.springframework.ai.mcp.util.Assert;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -113,6 +116,14 @@ public class McpSyncServer {
|
||||||
this.asyncServer.notifyPromptsListChanged().block();
|
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.
|
* Close the server gracefully.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,57 @@ public class McpSchema {
|
||||||
|
|
||||||
public static final String JSONRPC_VERSION = "2.0";
|
public static final String JSONRPC_VERSION = "2.0";
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// Method Names
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
|
// Lifecycle Methods
|
||||||
|
public static final String METHOD_INITIALIZE = "initialize";
|
||||||
|
|
||||||
|
public static final String METHOD_NOTIFICATION_INITIALIZED = "notifications/initialized";
|
||||||
|
|
||||||
|
public static final String METHOD_PING = "ping";
|
||||||
|
|
||||||
|
// Tool Methods
|
||||||
|
public static final String METHOD_TOOLS_LIST = "tools/list";
|
||||||
|
|
||||||
|
public static final String METHOD_TOOLS_CALL = "tools/call";
|
||||||
|
|
||||||
|
public static final String METHOD_NOTIFICATION_TOOLS_LIST_CHANGED = "notifications/tools/list_changed";
|
||||||
|
|
||||||
|
// Resources Methods
|
||||||
|
public static final String METHOD_RESOURCES_LIST = "resources/list";
|
||||||
|
|
||||||
|
public static final String METHOD_RESOURCES_READ = "resources/read";
|
||||||
|
|
||||||
|
public static final String METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED = "notifications/resources/list_changed";
|
||||||
|
|
||||||
|
public static final String METHOD_RESOURCES_TEMPLATES_LIST = "resources/templates/list";
|
||||||
|
|
||||||
|
public static final String METHOD_RESOURCES_SUBSCRIBE = "resources/subscribe";
|
||||||
|
|
||||||
|
public static final String METHOD_RESOURCES_UNSUBSCRIBE = "resources/unsubscribe";
|
||||||
|
|
||||||
|
// Prompt Methods
|
||||||
|
public static final String METHOD_PROMPT_LIST = "prompts/list";
|
||||||
|
|
||||||
|
public static final String METHOD_PROMPT_GET = "prompts/get";
|
||||||
|
|
||||||
|
public static final String METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED = "notifications/prompts/list_changed";
|
||||||
|
|
||||||
|
// Logging Methods
|
||||||
|
public static final String METHOD_LOGGING_SET_LEVEL = "logging/setLevel";
|
||||||
|
|
||||||
|
public static final String METHOD_NOTIFICATION_MESSAGE = "notifications/message";
|
||||||
|
|
||||||
|
// Roots Methods
|
||||||
|
public static final String METHOD_ROOTS_LIST = "roots/list";
|
||||||
|
|
||||||
|
public static final String METHOD_NOTIFICATION_ROOTS_LIST_CHANGED = "notifications/roots/list_changed";
|
||||||
|
|
||||||
|
// Sampling Methods
|
||||||
|
public static final String METHOD_SAMPLING_CREATE_MESSAGE = "sampling/createMessage";
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
// JSON-RPC Error Codes
|
// JSON-RPC Error Codes
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
|
|
@ -254,11 +305,16 @@ public class McpSchema {
|
||||||
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
||||||
public record ServerCapabilities( // @formatter:off
|
public record ServerCapabilities( // @formatter:off
|
||||||
@JsonProperty("experimental") Map<String, Object> experimental,
|
@JsonProperty("experimental") Map<String, Object> experimental,
|
||||||
@JsonProperty("logging") Object logging,
|
@JsonProperty("logging") LoggingCapabilities logging,
|
||||||
@JsonProperty("prompts") PromptCapabilities prompts,
|
@JsonProperty("prompts") PromptCapabilities prompts,
|
||||||
@JsonProperty("resources") ResourceCapabilities resources,
|
@JsonProperty("resources") ResourceCapabilities resources,
|
||||||
@JsonProperty("tools") ToolCapabilities tools) {
|
@JsonProperty("tools") ToolCapabilities tools) {
|
||||||
|
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
||||||
|
public record LoggingCapabilities() {
|
||||||
|
}
|
||||||
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
||||||
public record PromptCapabilities(
|
public record PromptCapabilities(
|
||||||
@JsonProperty("listChanged") Boolean listChanged) {
|
@JsonProperty("listChanged") Boolean listChanged) {
|
||||||
|
|
@ -282,7 +338,7 @@ public class McpSchema {
|
||||||
public static class Builder {
|
public static class Builder {
|
||||||
|
|
||||||
private Map<String, Object> experimental;
|
private Map<String, Object> experimental;
|
||||||
private Object logging;
|
private LoggingCapabilities logging = new LoggingCapabilities();
|
||||||
private PromptCapabilities prompts;
|
private PromptCapabilities prompts;
|
||||||
private ResourceCapabilities resources;
|
private ResourceCapabilities resources;
|
||||||
private ToolCapabilities tools;
|
private ToolCapabilities tools;
|
||||||
|
|
@ -292,8 +348,8 @@ public class McpSchema {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Builder logging(Object logging) {
|
public Builder logging() {
|
||||||
this.logging = logging;
|
this.logging = new LoggingCapabilities();
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -331,19 +387,6 @@ public class McpSchema {
|
||||||
@JsonProperty("assistant") ASSISTANT
|
@JsonProperty("assistant") ASSISTANT
|
||||||
}// @formatter:on
|
}// @formatter:on
|
||||||
|
|
||||||
public enum LoggingLevel {// @formatter:off
|
|
||||||
|
|
||||||
@JsonProperty("debug") DEBUG,
|
|
||||||
@JsonProperty("info") INFO,
|
|
||||||
@JsonProperty("notice") NOTICE,
|
|
||||||
@JsonProperty("warning") WARNING,
|
|
||||||
@JsonProperty("error") ERROR,
|
|
||||||
@JsonProperty("critical") CRITICAL,
|
|
||||||
@JsonProperty("alert") ALERT,
|
|
||||||
@JsonProperty("emergency") EMERGENCY
|
|
||||||
|
|
||||||
} // @formatter:on
|
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
// Resource Interfaces
|
// Resource Interfaces
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
|
|
@ -752,12 +795,73 @@ public class McpSchema {
|
||||||
@JsonProperty("total") Double total) {
|
@JsonProperty("total") Double total) {
|
||||||
}// @formatter:on
|
}// @formatter:on
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Model Context Protocol (MCP) provides a standardized way for servers to send
|
||||||
|
* structured log messages to clients. Clients can control logging verbosity by
|
||||||
|
* setting minimum log levels, with servers sending notifications containing severity
|
||||||
|
* levels, optional logger names, and arbitrary JSON-serializable data.
|
||||||
|
*
|
||||||
|
* @param level The severity levels. The mimimum log level is set by the client.
|
||||||
|
* @param logger The logger that generated the message.
|
||||||
|
* @param data JSON-serializable logging data.
|
||||||
|
*/
|
||||||
public record LoggingMessageNotification(// @formatter:off
|
public record LoggingMessageNotification(// @formatter:off
|
||||||
@JsonProperty("level") LoggingLevel level,
|
@JsonProperty("level") LoggingLevel level,
|
||||||
@JsonProperty("logger") String logger,
|
@JsonProperty("logger") String logger,
|
||||||
@JsonProperty("data") Object data) {
|
@JsonProperty("data") String data) {
|
||||||
|
|
||||||
|
public static Builder builder() {
|
||||||
|
return new Builder();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Builder {
|
||||||
|
private LoggingLevel level = LoggingLevel.INFO;
|
||||||
|
private String logger = "server";
|
||||||
|
private String data;
|
||||||
|
|
||||||
|
public Builder level(LoggingLevel level) {
|
||||||
|
this.level = level;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder logger(String logger) {
|
||||||
|
this.logger = logger;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder data(String data) {
|
||||||
|
this.data = data;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LoggingMessageNotification build() {
|
||||||
|
return new LoggingMessageNotification(level, logger, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
}// @formatter:on
|
}// @formatter:on
|
||||||
|
|
||||||
|
public enum LoggingLevel {// @formatter:off
|
||||||
|
@JsonProperty("debug") DEBUG(0),
|
||||||
|
@JsonProperty("info") INFO(1),
|
||||||
|
@JsonProperty("notice") NOTICE(2),
|
||||||
|
@JsonProperty("warning") WARNING(3),
|
||||||
|
@JsonProperty("error") ERROR(4),
|
||||||
|
@JsonProperty("critical") CRITICAL(5),
|
||||||
|
@JsonProperty("alert") ALERT(6),
|
||||||
|
@JsonProperty("emergency") EMERGENCY(7);
|
||||||
|
|
||||||
|
private final int level;
|
||||||
|
|
||||||
|
LoggingLevel(int level) {
|
||||||
|
this.level = level;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int level() {
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // @formatter:on
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
// Autocomplete
|
// Autocomplete
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import org.junit.jupiter.api.Disabled;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import reactor.test.StepVerifier;
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
|
import org.springframework.ai.mcp.spec.McpSchema;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.CallToolRequest;
|
import org.springframework.ai.mcp.spec.McpSchema.CallToolRequest;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities;
|
import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.GetPromptRequest;
|
import org.springframework.ai.mcp.spec.McpSchema.GetPromptRequest;
|
||||||
|
|
@ -322,4 +323,38 @@ public abstract class AbstractMcpAsyncClientTests {
|
||||||
}).doesNotThrowAnyException();
|
}).doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// Logging Tests
|
||||||
|
// ---------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testLoggingLevels() {
|
||||||
|
// Test all logging levels
|
||||||
|
for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
|
||||||
|
StepVerifier.create(mcpAsyncClient.setLoggingLevel(level)).verifyComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testLoggingConsumer() {
|
||||||
|
AtomicBoolean logReceived = new AtomicBoolean(false);
|
||||||
|
var transport = createMcpTransport();
|
||||||
|
|
||||||
|
var client = McpClient.using(transport)
|
||||||
|
.requestTimeout(TIMEOUT)
|
||||||
|
.loggingConsumer(notification -> logReceived.set(true))
|
||||||
|
.async();
|
||||||
|
|
||||||
|
assertThatCode(() -> {
|
||||||
|
client.initialize().block(Duration.ofSeconds(10));
|
||||||
|
client.closeGracefully().block(Duration.ofSeconds(10));
|
||||||
|
}).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testLoggingWithNullNotification() {
|
||||||
|
assertThatThrownBy(() -> mcpAsyncClient.setLoggingLevel(null).block())
|
||||||
|
.hasMessageContaining("Logging level must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import org.springframework.ai.mcp.spec.McpSchema;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.CallToolRequest;
|
import org.springframework.ai.mcp.spec.McpSchema.CallToolRequest;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
|
import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities;
|
import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities;
|
||||||
|
|
@ -277,4 +278,38 @@ public abstract class AbstractMcpSyncClientTests {
|
||||||
}).doesNotThrowAnyException();
|
}).doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// Logging Tests
|
||||||
|
// ---------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testLoggingLevels() {
|
||||||
|
// Test all logging levels
|
||||||
|
for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
|
||||||
|
assertThatCode(() -> mcpSyncClient.setLoggingLevel(level)).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testLoggingConsumer() {
|
||||||
|
AtomicBoolean logReceived = new AtomicBoolean(false);
|
||||||
|
var transport = createMcpTransport();
|
||||||
|
|
||||||
|
var client = McpClient.using(transport)
|
||||||
|
.requestTimeout(TIMEOUT)
|
||||||
|
.loggingConsumer(notification -> logReceived.set(true))
|
||||||
|
.sync();
|
||||||
|
|
||||||
|
assertThatCode(() -> {
|
||||||
|
client.initialize();
|
||||||
|
client.close();
|
||||||
|
}).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testLoggingWithNullNotification() {
|
||||||
|
assertThatThrownBy(() -> mcpSyncClient.setLoggingLevel(null))
|
||||||
|
.hasMessageContaining("Logging level must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -136,12 +136,12 @@ class McpAsyncClientResponseHandlerTests {
|
||||||
|
|
||||||
// Simulate server sending tools/list_changed notification
|
// Simulate server sending tools/list_changed notification
|
||||||
McpSchema.JSONRPCNotification notification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION,
|
McpSchema.JSONRPCNotification notification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION,
|
||||||
"notifications/tools/list_changed", null);
|
McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED, null);
|
||||||
transport.simulateIncomingMessage(notification);
|
transport.simulateIncomingMessage(notification);
|
||||||
|
|
||||||
// Simulate server response to tools/list request
|
// Simulate server response to tools/list request
|
||||||
McpSchema.JSONRPCRequest toolsListRequest = transport.getLastSentMessageAsRequest();
|
McpSchema.JSONRPCRequest toolsListRequest = transport.getLastSentMessageAsRequest();
|
||||||
assertThat(toolsListRequest.method()).isEqualTo("tools/list");
|
assertThat(toolsListRequest.method()).isEqualTo(McpSchema.METHOD_TOOLS_LIST);
|
||||||
|
|
||||||
McpSchema.JSONRPCResponse toolsListResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION,
|
McpSchema.JSONRPCResponse toolsListResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION,
|
||||||
toolsListRequest.id(), mockToolsResult, null);
|
toolsListRequest.id(), mockToolsResult, null);
|
||||||
|
|
@ -166,8 +166,8 @@ class McpAsyncClientResponseHandlerTests {
|
||||||
.async();
|
.async();
|
||||||
|
|
||||||
// Simulate incoming request
|
// Simulate incoming request
|
||||||
McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, "roots/list",
|
McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION,
|
||||||
"test-id", null);
|
McpSchema.METHOD_ROOTS_LIST, "test-id", null);
|
||||||
transport.simulateIncomingMessage(request);
|
transport.simulateIncomingMessage(request);
|
||||||
|
|
||||||
// Verify response
|
// Verify response
|
||||||
|
|
@ -207,12 +207,12 @@ class McpAsyncClientResponseHandlerTests {
|
||||||
|
|
||||||
// Simulate server sending resources/list_changed notification
|
// Simulate server sending resources/list_changed notification
|
||||||
McpSchema.JSONRPCNotification notification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION,
|
McpSchema.JSONRPCNotification notification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION,
|
||||||
"notifications/resources/list_changed", null);
|
McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED, null);
|
||||||
transport.simulateIncomingMessage(notification);
|
transport.simulateIncomingMessage(notification);
|
||||||
|
|
||||||
// Simulate server response to resources/list request
|
// Simulate server response to resources/list request
|
||||||
McpSchema.JSONRPCRequest resourcesListRequest = transport.getLastSentMessageAsRequest();
|
McpSchema.JSONRPCRequest resourcesListRequest = transport.getLastSentMessageAsRequest();
|
||||||
assertThat(resourcesListRequest.method()).isEqualTo("resources/list");
|
assertThat(resourcesListRequest.method()).isEqualTo(McpSchema.METHOD_RESOURCES_LIST);
|
||||||
|
|
||||||
McpSchema.JSONRPCResponse resourcesListResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION,
|
McpSchema.JSONRPCResponse resourcesListResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION,
|
||||||
resourcesListRequest.id(), mockResourcesResult, null);
|
resourcesListRequest.id(), mockResourcesResult, null);
|
||||||
|
|
@ -251,12 +251,12 @@ class McpAsyncClientResponseHandlerTests {
|
||||||
|
|
||||||
// Simulate server sending prompts/list_changed notification
|
// Simulate server sending prompts/list_changed notification
|
||||||
McpSchema.JSONRPCNotification notification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION,
|
McpSchema.JSONRPCNotification notification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION,
|
||||||
"notifications/prompts/list_changed", null);
|
McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED, null);
|
||||||
transport.simulateIncomingMessage(notification);
|
transport.simulateIncomingMessage(notification);
|
||||||
|
|
||||||
// Simulate server response to prompts/list request
|
// Simulate server response to prompts/list request
|
||||||
McpSchema.JSONRPCRequest promptsListRequest = transport.getLastSentMessageAsRequest();
|
McpSchema.JSONRPCRequest promptsListRequest = transport.getLastSentMessageAsRequest();
|
||||||
assertThat(promptsListRequest.method()).isEqualTo("prompts/list");
|
assertThat(promptsListRequest.method()).isEqualTo(McpSchema.METHOD_PROMPT_LIST);
|
||||||
|
|
||||||
McpSchema.JSONRPCResponse promptsListResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION,
|
McpSchema.JSONRPCResponse promptsListResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION,
|
||||||
promptsListRequest.id(), mockPromptsResult, null);
|
promptsListRequest.id(), mockPromptsResult, null);
|
||||||
|
|
@ -303,7 +303,7 @@ class McpAsyncClientResponseHandlerTests {
|
||||||
|
|
||||||
// Simulate incoming request
|
// Simulate incoming request
|
||||||
McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION,
|
McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION,
|
||||||
"sampling/createMessage", "test-id", messageRequest);
|
McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, "test-id", messageRequest);
|
||||||
transport.simulateIncomingMessage(request);
|
transport.simulateIncomingMessage(request);
|
||||||
|
|
||||||
// Verify response
|
// Verify response
|
||||||
|
|
@ -342,7 +342,7 @@ class McpAsyncClientResponseHandlerTests {
|
||||||
|
|
||||||
// Simulate incoming request
|
// Simulate incoming request
|
||||||
McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION,
|
McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION,
|
||||||
"sampling/createMessage", "test-id", messageRequest);
|
McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, "test-id", messageRequest);
|
||||||
transport.simulateIncomingMessage(request);
|
transport.simulateIncomingMessage(request);
|
||||||
|
|
||||||
// Verify error response
|
// Verify error response
|
||||||
|
|
@ -374,7 +374,7 @@ class McpAsyncClientResponseHandlerTests {
|
||||||
|
|
||||||
// Simulate incoming request
|
// Simulate incoming request
|
||||||
McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION,
|
McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION,
|
||||||
"sampling/createMessage", "test-id", messageRequest);
|
McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, "test-id", messageRequest);
|
||||||
transport.simulateIncomingMessage(request);
|
transport.simulateIncomingMessage(request);
|
||||||
|
|
||||||
// Verify error response
|
// Verify error response
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,10 @@ public abstract class AbstractMcpAsyncServerTests {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// Server Lifecycle Tests
|
||||||
|
// ---------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testConstructorWithInvalidArguments() {
|
void testConstructorWithInvalidArguments() {
|
||||||
assertThatThrownBy(() -> McpServer.using(null)).isInstanceOf(IllegalArgumentException.class)
|
assertThatThrownBy(() -> McpServer.using(null)).isInstanceOf(IllegalArgumentException.class)
|
||||||
|
|
@ -85,6 +89,24 @@ public abstract class AbstractMcpAsyncServerTests {
|
||||||
.hasMessage("Server info must not be null");
|
.hasMessage("Server info must not be null");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGracefulShutdown() {
|
||||||
|
var mcpAsyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").async();
|
||||||
|
|
||||||
|
StepVerifier.create(mcpAsyncServer.closeGracefully()).verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testImmediateClose() {
|
||||||
|
var mcpAsyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").async();
|
||||||
|
|
||||||
|
assertThatCode(() -> mcpAsyncServer.close()).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// Tools Tests
|
||||||
|
// ---------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testAddTool() {
|
void testAddTool() {
|
||||||
Tool newTool = new McpSchema.Tool("new-tool", "New test tool", Map.of("input", "string"));
|
Tool newTool = new McpSchema.Tool("new-tool", "New test tool", Map.of("input", "string"));
|
||||||
|
|
@ -165,6 +187,10 @@ public abstract class AbstractMcpAsyncServerTests {
|
||||||
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10)));
|
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// Resources Tests
|
||||||
|
// ---------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testNotifyResourcesListChanged() {
|
void testNotifyResourcesListChanged() {
|
||||||
var mcpAsyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").async();
|
var mcpAsyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").async();
|
||||||
|
|
@ -174,29 +200,6 @@ public abstract class AbstractMcpAsyncServerTests {
|
||||||
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10)));
|
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void testNotifyPromptsListChanged() {
|
|
||||||
var mcpAsyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").async();
|
|
||||||
|
|
||||||
StepVerifier.create(mcpAsyncServer.notifyPromptsListChanged()).verifyComplete();
|
|
||||||
|
|
||||||
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10)));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void testGracefulShutdown() {
|
|
||||||
var mcpAsyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").async();
|
|
||||||
|
|
||||||
StepVerifier.create(mcpAsyncServer.closeGracefully()).verifyComplete();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void testImmediateClose() {
|
|
||||||
var mcpAsyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").async();
|
|
||||||
|
|
||||||
assertThatCode(() -> mcpAsyncServer.close()).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testAddResource() {
|
void testAddResource() {
|
||||||
var mcpAsyncServer = McpServer.using(createMcpTransport())
|
var mcpAsyncServer = McpServer.using(createMcpTransport())
|
||||||
|
|
@ -216,7 +219,6 @@ public abstract class AbstractMcpAsyncServerTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testAddResourceWithNullRegistration() {
|
void testAddResourceWithNullRegistration() {
|
||||||
|
|
||||||
var mcpAsyncServer = McpServer.using(createMcpTransport())
|
var mcpAsyncServer = McpServer.using(createMcpTransport())
|
||||||
.info("test-server", "1.0.0")
|
.info("test-server", "1.0.0")
|
||||||
.capabilities(ServerCapabilities.builder().resources(true, false).build())
|
.capabilities(ServerCapabilities.builder().resources(true, false).build())
|
||||||
|
|
@ -229,18 +231,6 @@ public abstract class AbstractMcpAsyncServerTests {
|
||||||
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
|
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void testAddPromptWithNullRegistration() {
|
|
||||||
var mcpAsyncServer = McpServer.using(createMcpTransport())
|
|
||||||
.info("test-server", "1.0.0")
|
|
||||||
.capabilities(ServerCapabilities.builder().prompts(false).build())
|
|
||||||
.async();
|
|
||||||
|
|
||||||
StepVerifier.create(mcpAsyncServer.addPrompt(null)).verifyErrorSatisfies(error -> {
|
|
||||||
assertThat(error).isInstanceOf(McpError.class).hasMessage("Prompt registration must not be null");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testAddResourceWithoutCapability() {
|
void testAddResourceWithoutCapability() {
|
||||||
// Create a server without resource capabilities
|
// Create a server without resource capabilities
|
||||||
|
|
@ -259,6 +249,44 @@ public abstract class AbstractMcpAsyncServerTests {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testRemoveResourceWithoutCapability() {
|
||||||
|
// Create a server without resource capabilities
|
||||||
|
McpAsyncServer serverWithoutResources = McpServer.using(createMcpTransport())
|
||||||
|
.info("test-server", "1.0.0")
|
||||||
|
.async();
|
||||||
|
|
||||||
|
StepVerifier.create(serverWithoutResources.removeResource(TEST_RESOURCE_URI)).verifyErrorSatisfies(error -> {
|
||||||
|
assertThat(error).isInstanceOf(McpError.class)
|
||||||
|
.hasMessage("Server must be configured with resource capabilities");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// Prompts Tests
|
||||||
|
// ---------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testNotifyPromptsListChanged() {
|
||||||
|
var mcpAsyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").async();
|
||||||
|
|
||||||
|
StepVerifier.create(mcpAsyncServer.notifyPromptsListChanged()).verifyComplete();
|
||||||
|
|
||||||
|
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testAddPromptWithNullRegistration() {
|
||||||
|
var mcpAsyncServer = McpServer.using(createMcpTransport())
|
||||||
|
.info("test-server", "1.0.0")
|
||||||
|
.capabilities(ServerCapabilities.builder().prompts(false).build())
|
||||||
|
.async();
|
||||||
|
|
||||||
|
StepVerifier.create(mcpAsyncServer.addPrompt(null)).verifyErrorSatisfies(error -> {
|
||||||
|
assertThat(error).isInstanceOf(McpError.class).hasMessage("Prompt registration must not be null");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testAddPromptWithoutCapability() {
|
void testAddPromptWithoutCapability() {
|
||||||
// Create a server without prompt capabilities
|
// Create a server without prompt capabilities
|
||||||
|
|
@ -277,19 +305,6 @@ public abstract class AbstractMcpAsyncServerTests {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void testRemoveResourceWithoutCapability() {
|
|
||||||
// Create a server without resource capabilities
|
|
||||||
McpAsyncServer serverWithoutResources = McpServer.using(createMcpTransport())
|
|
||||||
.info("test-server", "1.0.0")
|
|
||||||
.async();
|
|
||||||
|
|
||||||
StepVerifier.create(serverWithoutResources.removeResource(TEST_RESOURCE_URI)).verifyErrorSatisfies(error -> {
|
|
||||||
assertThat(error).isInstanceOf(McpError.class)
|
|
||||||
.hasMessage("Server must be configured with resource capabilities");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testRemovePromptWithoutCapability() {
|
void testRemovePromptWithoutCapability() {
|
||||||
// Create a server without prompt capabilities
|
// Create a server without prompt capabilities
|
||||||
|
|
@ -339,4 +354,53 @@ public abstract class AbstractMcpAsyncServerTests {
|
||||||
.doesNotThrowAnyException();
|
.doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// Logging Tests
|
||||||
|
// ---------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testLoggingLevels() {
|
||||||
|
var mcpAsyncServer = McpServer.using(createMcpTransport())
|
||||||
|
.info("test-server", "1.0.0")
|
||||||
|
.capabilities(ServerCapabilities.builder().logging().build())
|
||||||
|
.async();
|
||||||
|
|
||||||
|
// Test all logging levels
|
||||||
|
for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
|
||||||
|
var notification = McpSchema.LoggingMessageNotification.builder()
|
||||||
|
.level(level)
|
||||||
|
.logger("test-logger")
|
||||||
|
.data("Test message with level " + level)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
StepVerifier.create(mcpAsyncServer.loggingNotification(notification)).verifyComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testLoggingWithoutCapability() {
|
||||||
|
var mcpAsyncServer = McpServer.using(createMcpTransport())
|
||||||
|
.info("test-server", "1.0.0")
|
||||||
|
.capabilities(ServerCapabilities.builder().build()) // No logging capability
|
||||||
|
.async();
|
||||||
|
|
||||||
|
var notification = McpSchema.LoggingMessageNotification.builder()
|
||||||
|
.level(McpSchema.LoggingLevel.INFO)
|
||||||
|
.logger("test-logger")
|
||||||
|
.data("Test log message")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
StepVerifier.create(mcpAsyncServer.loggingNotification(notification)).verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testLoggingWithNullNotification() {
|
||||||
|
var mcpAsyncServer = McpServer.using(createMcpTransport())
|
||||||
|
.info("test-server", "1.0.0")
|
||||||
|
.capabilities(ServerCapabilities.builder().logging().build())
|
||||||
|
.async();
|
||||||
|
|
||||||
|
StepVerifier.create(mcpAsyncServer.loggingNotification(null)).verifyError(McpError.class);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,10 @@ public abstract class AbstractMcpSyncServerTests {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// Server Lifecycle Tests
|
||||||
|
// ---------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testConstructorWithInvalidArguments() {
|
void testConstructorWithInvalidArguments() {
|
||||||
assertThatThrownBy(() -> McpServer.using(null)).isInstanceOf(IllegalArgumentException.class)
|
assertThatThrownBy(() -> McpServer.using(null)).isInstanceOf(IllegalArgumentException.class)
|
||||||
|
|
@ -84,6 +88,33 @@ public abstract class AbstractMcpSyncServerTests {
|
||||||
.hasMessage("Server info must not be null");
|
.hasMessage("Server info must not be null");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGracefulShutdown() {
|
||||||
|
var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
|
||||||
|
|
||||||
|
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testImmediateClose() {
|
||||||
|
var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
|
||||||
|
|
||||||
|
assertThatCode(() -> mcpSyncServer.close()).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetAsyncServer() {
|
||||||
|
var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
|
||||||
|
|
||||||
|
assertThat(mcpSyncServer.getAsyncServer()).isNotNull();
|
||||||
|
|
||||||
|
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// Tools Tests
|
||||||
|
// ---------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testAddTool() {
|
void testAddTool() {
|
||||||
var mcpSyncServer = McpServer.using(createMcpTransport())
|
var mcpSyncServer = McpServer.using(createMcpTransport())
|
||||||
|
|
@ -154,6 +185,10 @@ public abstract class AbstractMcpSyncServerTests {
|
||||||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// Resources Tests
|
||||||
|
// ---------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testNotifyResourcesListChanged() {
|
void testNotifyResourcesListChanged() {
|
||||||
var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
|
var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
|
||||||
|
|
@ -163,29 +198,6 @@ public abstract class AbstractMcpSyncServerTests {
|
||||||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void testNotifyPromptsListChanged() {
|
|
||||||
var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
|
|
||||||
|
|
||||||
assertThatCode(() -> mcpSyncServer.notifyPromptsListChanged()).doesNotThrowAnyException();
|
|
||||||
|
|
||||||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void testGracefulShutdown() {
|
|
||||||
var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
|
|
||||||
|
|
||||||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void testImmediateClose() {
|
|
||||||
var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
|
|
||||||
|
|
||||||
assertThatCode(() -> mcpSyncServer.close()).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testAddResource() {
|
void testAddResource() {
|
||||||
var mcpSyncServer = McpServer.using(createMcpTransport())
|
var mcpSyncServer = McpServer.using(createMcpTransport())
|
||||||
|
|
@ -216,17 +228,6 @@ public abstract class AbstractMcpSyncServerTests {
|
||||||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void testAddPromptWithNullRegistration() {
|
|
||||||
var mcpSyncServer = McpServer.using(createMcpTransport())
|
|
||||||
.info("test-server", "1.0.0")
|
|
||||||
.capabilities(ServerCapabilities.builder().prompts(false).build())
|
|
||||||
.sync();
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> mcpSyncServer.addPrompt(null)).isInstanceOf(McpError.class)
|
|
||||||
.hasMessage("Prompt registration must not be null");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testAddResourceWithoutCapability() {
|
void testAddResourceWithoutCapability() {
|
||||||
var serverWithoutResources = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
|
var serverWithoutResources = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
|
||||||
|
|
@ -240,6 +241,38 @@ public abstract class AbstractMcpSyncServerTests {
|
||||||
.hasMessage("Server must be configured with resource capabilities");
|
.hasMessage("Server must be configured with resource capabilities");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testRemoveResourceWithoutCapability() {
|
||||||
|
var serverWithoutResources = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> serverWithoutResources.removeResource(TEST_RESOURCE_URI)).isInstanceOf(McpError.class)
|
||||||
|
.hasMessage("Server must be configured with resource capabilities");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// Prompts Tests
|
||||||
|
// ---------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testNotifyPromptsListChanged() {
|
||||||
|
var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
|
||||||
|
|
||||||
|
assertThatCode(() -> mcpSyncServer.notifyPromptsListChanged()).doesNotThrowAnyException();
|
||||||
|
|
||||||
|
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testAddPromptWithNullRegistration() {
|
||||||
|
var mcpSyncServer = McpServer.using(createMcpTransport())
|
||||||
|
.info("test-server", "1.0.0")
|
||||||
|
.capabilities(ServerCapabilities.builder().prompts(false).build())
|
||||||
|
.sync();
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> mcpSyncServer.addPrompt(null)).isInstanceOf(McpError.class)
|
||||||
|
.hasMessage("Prompt registration must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testAddPromptWithoutCapability() {
|
void testAddPromptWithoutCapability() {
|
||||||
var serverWithoutPrompts = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
|
var serverWithoutPrompts = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
|
||||||
|
|
@ -253,14 +286,6 @@ public abstract class AbstractMcpSyncServerTests {
|
||||||
.hasMessage("Server must be configured with prompt capabilities");
|
.hasMessage("Server must be configured with prompt capabilities");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void testRemoveResourceWithoutCapability() {
|
|
||||||
var serverWithoutResources = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> serverWithoutResources.removeResource(TEST_RESOURCE_URI)).isInstanceOf(McpError.class)
|
|
||||||
.hasMessage("Server must be configured with resource capabilities");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testRemovePromptWithoutCapability() {
|
void testRemovePromptWithoutCapability() {
|
||||||
var serverWithoutPrompts = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
|
var serverWithoutPrompts = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
|
||||||
|
|
@ -271,7 +296,6 @@ public abstract class AbstractMcpSyncServerTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testRemovePrompt() {
|
void testRemovePrompt() {
|
||||||
|
|
||||||
Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of());
|
Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of());
|
||||||
PromptRegistration registration = new PromptRegistration(prompt, req -> new GetPromptResult(
|
PromptRegistration registration = new PromptRegistration(prompt, req -> new GetPromptResult(
|
||||||
"Test prompt description",
|
"Test prompt description",
|
||||||
|
|
@ -282,8 +306,6 @@ public abstract class AbstractMcpSyncServerTests {
|
||||||
.capabilities(ServerCapabilities.builder().prompts(true).build())
|
.capabilities(ServerCapabilities.builder().prompts(true).build())
|
||||||
.prompts(registration)
|
.prompts(registration)
|
||||||
.sync();
|
.sync();
|
||||||
// assertThatCode(() ->
|
|
||||||
// mcpSyncServer.addPrompt(registration)).doesNotThrowAnyException();
|
|
||||||
|
|
||||||
assertThatCode(() -> mcpSyncServer.removePrompt(TEST_PROMPT_NAME)).doesNotThrowAnyException();
|
assertThatCode(() -> mcpSyncServer.removePrompt(TEST_PROMPT_NAME)).doesNotThrowAnyException();
|
||||||
|
|
||||||
|
|
@ -303,13 +325,53 @@ public abstract class AbstractMcpSyncServerTests {
|
||||||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// Logging Tests
|
||||||
|
// ---------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testGetAsyncServer() {
|
void testLoggingLevels() {
|
||||||
var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
|
var mcpSyncServer = McpServer.using(createMcpTransport())
|
||||||
|
.info("test-server", "1.0.0")
|
||||||
|
.capabilities(ServerCapabilities.builder().logging().build())
|
||||||
|
.sync();
|
||||||
|
|
||||||
assertThat(mcpSyncServer.getAsyncServer()).isNotNull();
|
// Test all logging levels
|
||||||
|
for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
|
||||||
|
var notification = McpSchema.LoggingMessageNotification.builder()
|
||||||
|
.level(level)
|
||||||
|
.logger("test-logger")
|
||||||
|
.data("Test message with level " + level)
|
||||||
|
.build();
|
||||||
|
|
||||||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
assertThatCode(() -> mcpSyncServer.loggingNotification(notification)).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testLoggingWithoutCapability() {
|
||||||
|
var mcpSyncServer = McpServer.using(createMcpTransport())
|
||||||
|
.info("test-server", "1.0.0")
|
||||||
|
.capabilities(ServerCapabilities.builder().build()) // No logging capability
|
||||||
|
.sync();
|
||||||
|
|
||||||
|
var notification = McpSchema.LoggingMessageNotification.builder()
|
||||||
|
.level(McpSchema.LoggingLevel.INFO)
|
||||||
|
.logger("test-logger")
|
||||||
|
.data("Test log message")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
assertThatCode(() -> mcpSyncServer.loggingNotification(notification)).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testLoggingWithNullNotification() {
|
||||||
|
var mcpSyncServer = McpServer.using(createMcpTransport())
|
||||||
|
.info("test-server", "1.0.0")
|
||||||
|
.capabilities(ServerCapabilities.builder().logging().build())
|
||||||
|
.sync();
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> mcpSyncServer.loggingNotification(null)).isInstanceOf(McpError.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import org.springframework.ai.mcp.server.McpAsyncServer;
|
import org.springframework.ai.mcp.server.McpAsyncServer;
|
||||||
import org.springframework.ai.mcp.server.McpServer;
|
import org.springframework.ai.mcp.server.McpServer;
|
||||||
|
|
@ -15,6 +17,7 @@ import org.springframework.ai.mcp.server.transport.StdioServerTransport;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema;
|
import org.springframework.ai.mcp.spec.McpSchema;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
|
import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.GetPromptResult;
|
import org.springframework.ai.mcp.spec.McpSchema.GetPromptResult;
|
||||||
|
import org.springframework.ai.mcp.spec.McpSchema.LoggingMessageNotification;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.PromptMessage;
|
import org.springframework.ai.mcp.spec.McpSchema.PromptMessage;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.Role;
|
import org.springframework.ai.mcp.spec.McpSchema.Role;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.TextContent;
|
import org.springframework.ai.mcp.spec.McpSchema.TextContent;
|
||||||
|
|
@ -27,6 +30,8 @@ import org.springframework.web.reactive.function.server.RouterFunction;
|
||||||
@Configuration
|
@Configuration
|
||||||
public class McpServerConfig {
|
public class McpServerConfig {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(McpServerConfig.class);
|
||||||
|
|
||||||
// STDIO transport
|
// STDIO transport
|
||||||
@Bean
|
@Bean
|
||||||
@ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "stdio")
|
@ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "stdio")
|
||||||
|
|
@ -41,7 +46,8 @@ public class McpServerConfig {
|
||||||
return new SseServerTransport(new ObjectMapper(), "/mcp/message");
|
return new SseServerTransport(new ObjectMapper(), "/mcp/message");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Router function for SSE transport used by Spring WebFlux to start an HTTP server.
|
// Router function for SSE transport used by Spring WebFlux to start an HTTP
|
||||||
|
// server.
|
||||||
@Bean
|
@Bean
|
||||||
@ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "sse")
|
@ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "sse")
|
||||||
public RouterFunction<?> mcpRouterFunction(SseServerTransport transport) {
|
public RouterFunction<?> mcpRouterFunction(SseServerTransport transport) {
|
||||||
|
|
@ -56,16 +62,20 @@ public class McpServerConfig {
|
||||||
.resources(false, true) // No subscribe support, but list changes notifications
|
.resources(false, true) // No subscribe support, but list changes notifications
|
||||||
.tools(true) // Tool support with list changes notifications
|
.tools(true) // Tool support with list changes notifications
|
||||||
.prompts(true) // Prompt support with list changes notifications
|
.prompts(true) // Prompt support with list changes notifications
|
||||||
|
.logging() // Logging support
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Create the server with both tool and resource capabilities
|
// Create the server with both tool and resource capabilities
|
||||||
return McpServer.using(transport)
|
var server = McpServer.using(transport)
|
||||||
.info("MCP Demo Server", "1.0.0")
|
.info("MCP Demo Server", "1.0.0")
|
||||||
.capabilities(capabilities)
|
.capabilities(capabilities)
|
||||||
.resources(systemInfoResourceRegistration())
|
.resources(systemInfoResourceRegistration())
|
||||||
.prompts(greetingPromptRegistration())
|
.prompts(greetingPromptRegistration())
|
||||||
.tools(weatherToolRegistration(), calculatorToolRegistration())
|
.tools(calculatorToolRegistration())
|
||||||
.async();
|
.async();
|
||||||
|
|
||||||
|
server.addTool(weatherToolRegistration(server));
|
||||||
|
return server; // @formatter:on
|
||||||
} // @formatter:on
|
} // @formatter:on
|
||||||
|
|
||||||
private static ResourceRegistration systemInfoResourceRegistration() {
|
private static ResourceRegistration systemInfoResourceRegistration() {
|
||||||
|
|
@ -120,13 +130,27 @@ public class McpServerConfig {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ToolRegistration weatherToolRegistration() {
|
private static ToolRegistration weatherToolRegistration(McpAsyncServer server) {
|
||||||
return new ToolRegistration(
|
return new ToolRegistration(
|
||||||
new McpSchema.Tool("weather", "Weather forecast tool by location", Map.of("city", "String")),
|
new McpSchema.Tool("weather", "Weather forecast tool by location", Map.of("city", "String")),
|
||||||
(arguments) -> {
|
(arguments) -> {
|
||||||
String city = (String) arguments.get("city");
|
String city = (String) arguments.get("city");
|
||||||
TextContent content = new TextContent("Weather forecast for " + city + " is sunny");
|
|
||||||
return new CallToolResult(List.of(content), false);
|
// Create the result
|
||||||
|
var result = new CallToolResult(
|
||||||
|
List.of(new TextContent("Weather forecast for " + city + " is sunny")), false);
|
||||||
|
|
||||||
|
// Send the logging notification and ignore its completion
|
||||||
|
server
|
||||||
|
.loggingNotification(LoggingMessageNotification.builder()
|
||||||
|
.data("This is a log message from the weather tool")
|
||||||
|
.build())
|
||||||
|
.subscribe(null, error -> {
|
||||||
|
// Log any errors but don't fail the operation
|
||||||
|
logger.error("Failed to send logging notification", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ import org.springframework.ai.model.function.FunctionCallback;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author Christian Tzolov
|
* @author Christian Tzolov
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public class McpFunctionCallback implements FunctionCallback {
|
public class McpFunctionCallback implements FunctionCallback {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue