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:
|
||||
- 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.
|
||||
- [Find more](./mcp/README.md).
|
||||
|
||||
|
|
|
|||
|
|
@ -136,10 +136,6 @@ McpServer.using(transport)
|
|||
.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
|
||||
- The synchronous server is implemented as a wrapper around the asynchronous server
|
||||
- Resource and prompt providers use cursor-based pagination
|
||||
|
|
@ -160,3 +156,59 @@ Tool handlers implement this interface to define:
|
|||
- Tool name and description
|
||||
- Input schema for validation
|
||||
- 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
|
||||
|
||||
### MCP Server (Sync API)
|
||||
|
|
@ -307,10 +364,36 @@ var capabilities = ServerCapabilities.builder()
|
|||
.resources(false, true) // Resource support with list changes notifications
|
||||
.tools(true) // Tool 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();
|
||||
```
|
||||
|
||||
### 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
|
||||
var toolRegistration = new ToolRegistration(
|
||||
|
|
@ -326,7 +409,7 @@ var toolRegistration = new ToolRegistration(
|
|||
);
|
||||
```
|
||||
|
||||
### Resource Registration
|
||||
#### Resource Registration
|
||||
|
||||
```java
|
||||
var resourceRegistration = new ResourceRegistration(
|
||||
|
|
@ -338,7 +421,7 @@ var resourceRegistration = new ResourceRegistration(
|
|||
);
|
||||
```
|
||||
|
||||
### Prompt Registration
|
||||
#### Prompt Registration
|
||||
|
||||
```java
|
||||
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.Implementation;
|
||||
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.Root;
|
||||
import org.springframework.ai.mcp.spec.McpTransport;
|
||||
|
|
@ -114,6 +116,7 @@ public class McpAsyncClient {
|
|||
* @param toolsChangeConsumers the tools change consumers.
|
||||
* @param resourcesChangeConsumers the resources change consumers.
|
||||
* @param promptsChangeConsumers the prompts change consumers.
|
||||
* @param loggingConsumers the logging consumers.
|
||||
* @param samplingHandler the sampling handler.
|
||||
*/
|
||||
public McpAsyncClient(McpTransport transport, Duration requestTimeout, Implementation clientInfo,
|
||||
|
|
@ -121,6 +124,7 @@ public class McpAsyncClient {
|
|||
List<Consumer<List<McpSchema.Tool>>> toolsChangeConsumers,
|
||||
List<Consumer<List<McpSchema.Resource>>> resourcesChangeConsumers,
|
||||
List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers,
|
||||
List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers,
|
||||
Function<CreateMessageRequest, CreateMessageResult> samplingHandler) {
|
||||
|
||||
Assert.notNull(transport, "Transport must not be null");
|
||||
|
|
@ -142,13 +146,13 @@ public class McpAsyncClient {
|
|||
|
||||
// Roots List Request Handler
|
||||
if (!Utils.isEmpty(this.roots) && this.clientCapabilities.roots() != null) {
|
||||
requestHanlers.put("roots/list", rootsListRequestHandler());
|
||||
requestHanlers.put(McpSchema.METHOD_ROOTS_LIST, rootsListRequestHandler());
|
||||
}
|
||||
|
||||
// Sampling Handler
|
||||
if (samplingHandler != null && this.clientCapabilities.sampling() != null) {
|
||||
this.samplingHandler = samplingHandler;
|
||||
requestHanlers.put("sampling/createMessage", samplingCreateMessageHandler());
|
||||
requestHanlers.put(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, samplingCreateMessageHandler());
|
||||
}
|
||||
|
||||
// Notification Handlers
|
||||
|
|
@ -160,7 +164,7 @@ public class McpAsyncClient {
|
|||
if (!Utils.isEmpty(toolsChangeConsumers)) {
|
||||
toolsChangeConsumersFinal.addAll(toolsChangeConsumers);
|
||||
}
|
||||
notificationHandlers.put("notifications/tools/list_changed",
|
||||
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED,
|
||||
toolsChangeNotificationHandler(toolsChangeConsumersFinal));
|
||||
|
||||
// Resources Change Notification
|
||||
|
|
@ -169,7 +173,7 @@ public class McpAsyncClient {
|
|||
if (!Utils.isEmpty(resourcesChangeConsumers)) {
|
||||
resourcesChangeConsumersFinal.addAll(resourcesChangeConsumers);
|
||||
}
|
||||
notificationHandlers.put("notifications/resources/list_changed",
|
||||
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED,
|
||||
resourcesChangeNotificationHandler(resourcesChangeConsumersFinal));
|
||||
|
||||
// Prompts Change Notification
|
||||
|
|
@ -178,9 +182,18 @@ public class McpAsyncClient {
|
|||
if (!Utils.isEmpty(promptsChangeConsumers)) {
|
||||
promptsChangeConsumersFinal.addAll(promptsChangeConsumers);
|
||||
}
|
||||
notificationHandlers.put("notifications/prompts/list_changed",
|
||||
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED,
|
||||
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);
|
||||
|
||||
}
|
||||
|
|
@ -222,8 +235,8 @@ public class McpAsyncClient {
|
|||
this.clientCapabilities,
|
||||
this.clientInfo); // @formatter:on
|
||||
|
||||
Mono<McpSchema.InitializeResult> result = this.mcpSession.sendRequest("initialize", initializeRequest,
|
||||
new TypeReference<McpSchema.InitializeResult>() {
|
||||
Mono<McpSchema.InitializeResult> result = this.mcpSession.sendRequest(McpSchema.METHOD_INITIALIZE,
|
||||
initializeRequest, new TypeReference<McpSchema.InitializeResult>() {
|
||||
});
|
||||
|
||||
return result.flatMap(initializeResult -> {
|
||||
|
|
@ -237,7 +250,8 @@ public class McpAsyncClient {
|
|||
"Unsupported protocol version from the server: " + initializeResult.protocolVersion()));
|
||||
}
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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() {
|
||||
|
|
@ -387,7 +401,7 @@ public class McpAsyncClient {
|
|||
* (false/absent)
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -427,20 +441,14 @@ public class McpAsyncClient {
|
|||
private NotificationHandler toolsChangeNotificationHandler(
|
||||
List<Consumer<List<McpSchema.Tool>>> toolsChangeConsumers) {
|
||||
|
||||
return new NotificationHandler() {
|
||||
@Override
|
||||
public Mono<Void> handle(Object params) {
|
||||
// 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>
|
||||
return params -> 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>
|
||||
};
|
||||
|
||||
// --------------------------
|
||||
|
|
@ -470,7 +478,7 @@ public class McpAsyncClient {
|
|||
* @return A Mono that completes with the list of resources result
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -489,7 +497,8 @@ public class McpAsyncClient {
|
|||
* @return A Mono that completes with the resource content
|
||||
*/
|
||||
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
|
||||
*/
|
||||
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates(String cursor) {
|
||||
return this.mcpSession.sendRequest("resources/templates/list", new McpSchema.PaginatedRequest(cursor),
|
||||
LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF);
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST,
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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(
|
||||
List<Consumer<List<McpSchema.Resource>>> resourcesChangeConsumers) {
|
||||
|
||||
return new NotificationHandler() {
|
||||
@Override
|
||||
public Mono<Void> handle(Object params) {
|
||||
// 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>
|
||||
return params -> 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();
|
||||
};
|
||||
|
||||
// --------------------------
|
||||
|
|
@ -591,7 +595,8 @@ public class McpAsyncClient {
|
|||
* @return A Mono that completes with the list of prompts result
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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(
|
||||
List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers) {
|
||||
|
||||
return new NotificationHandler() {
|
||||
@Override
|
||||
public Mono<Void> handle(Object params) {
|
||||
// TODO: add support for cursor/pagination
|
||||
return listPrompts().flatMap(listPromptsResult -> Mono.fromRunnable(() -> {
|
||||
for (Consumer<List<McpSchema.Prompt>> promptChangeConsumer : promptsChangeConsumers) {
|
||||
promptChangeConsumer.accept(listPromptsResult.prompts());
|
||||
}
|
||||
}).subscribeOn(Schedulers.boundedElastic())).onErrorResume(error -> {
|
||||
logger.error("Error handling prompts list change notification", error);
|
||||
return Mono.empty();
|
||||
}).then(); // Convert to Mono<Void>
|
||||
}
|
||||
};
|
||||
return params -> {// @formatter:off
|
||||
return listPrompts().flatMap(listPromptsResult -> Mono.fromRunnable(() -> {
|
||||
for (Consumer<List<McpSchema.Prompt>> promptChangeConsumer : promptsChangeConsumers) {
|
||||
promptChangeConsumer.accept(listPromptsResult.prompts());
|
||||
}
|
||||
}).subscribeOn(Schedulers.boundedElastic())).onErrorResume(error -> {
|
||||
logger.error("Error handling prompts list change notification", error);
|
||||
return Mono.empty();
|
||||
}).then(); // Convert to Mono<Void>
|
||||
}; // @formatter:on
|
||||
};
|
||||
|
||||
// --------------------------
|
||||
// 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"))
|
||||
* .roots(new Root("file://workspace", "Workspace Files"))
|
||||
* .toolsChangeConsumer(tools -> System.out.println("Tools updated: " + tools))
|
||||
* .resourcesChangeConsumer(resources -> System.out.println("Resources updated: " + resources))
|
||||
* .promptsChangeConsumer(prompts -> System.out.println("Prompts updated: " + prompts))
|
||||
* .loggingConsumer(message -> System.out.println("Log message: " + message))
|
||||
* .async();
|
||||
* }</pre>
|
||||
*
|
||||
|
|
@ -82,6 +85,16 @@ import org.springframework.ai.mcp.util.Assert;
|
|||
* <li>Prompt template handling
|
||||
* <li>Real-time updates through change consumers
|
||||
* <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>
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
|
|
@ -141,6 +154,8 @@ public interface McpClient {
|
|||
|
||||
private List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers = new ArrayList<>();
|
||||
|
||||
private List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers = new ArrayList<>();
|
||||
|
||||
private Function<CreateMessageRequest, CreateMessageResult> samplingHandler;
|
||||
|
||||
private Builder(McpTransport transport) {
|
||||
|
|
@ -284,6 +299,34 @@ public interface McpClient {
|
|||
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
|
||||
* clients wait for each operation to complete before returning, making them
|
||||
|
|
@ -304,7 +347,7 @@ public interface McpClient {
|
|||
*/
|
||||
public McpAsyncClient async() {
|
||||
return new McpAsyncClient(transport, requestTimeout, clientInfo, capabilities, roots, toolsChangeConsumers,
|
||||
resourcesChangeConsumers, promptsChangeConsumers, samplingHandler);
|
||||
resourcesChangeConsumers, promptsChangeConsumers, loggingConsumers, samplingHandler);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import org.springframework.ai.mcp.util.Assert;
|
|||
|
||||
/**
|
||||
* A synchronous client for the Model Context Protocol (MCP).
|
||||
*
|
||||
*
|
||||
* @author Dariusz Jędrzejczyk
|
||||
* @author Christian Tzolov
|
||||
*/
|
||||
|
|
@ -280,4 +280,12 @@ public class McpSyncClient implements AutoCloseable {
|
|||
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;
|
||||
|
||||
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.McpSchema;
|
||||
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.McpTransport;
|
||||
import org.springframework.ai.mcp.util.Utils;
|
||||
|
|
@ -59,6 +77,9 @@ public class McpAsyncServer {
|
|||
|
||||
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.
|
||||
* @param mcpTransport The transport layer implementation for MCP communication
|
||||
|
|
@ -83,7 +104,8 @@ public class McpAsyncServer {
|
|||
|
||||
this.serverCapabilities = (serverCapabilities != null) ? serverCapabilities : new McpSchema.ServerCapabilities(
|
||||
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.resources) ? new McpSchema.ServerCapabilities.ResourceCapabilities(false, false)
|
||||
: null,
|
||||
|
|
@ -92,37 +114,42 @@ public class McpAsyncServer {
|
|||
Map<String, DefaultMcpSession.RequestHandler> requestHandlers = new HashMap<>();
|
||||
|
||||
// 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.
|
||||
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
|
||||
if (this.serverCapabilities.tools() != null) {
|
||||
requestHandlers.put("tools/list", toolsListRequestHandler());
|
||||
requestHandlers.put("tools/call", toolsCallRequestHandler());
|
||||
requestHandlers.put(McpSchema.METHOD_TOOLS_LIST, toolsListRequestHandler());
|
||||
requestHandlers.put(McpSchema.METHOD_TOOLS_CALL, toolsCallRequestHandler());
|
||||
}
|
||||
|
||||
// Add resources API handlers if provided
|
||||
if (!Utils.isEmpty(this.resources)) {
|
||||
requestHandlers.put("resources/list", resourcesListRequestHandler());
|
||||
requestHandlers.put("resources/read", resourcesReadRequestHandler());
|
||||
requestHandlers.put(McpSchema.METHOD_RESOURCES_LIST, resourcesListRequestHandler());
|
||||
requestHandlers.put(McpSchema.METHOD_RESOURCES_READ, resourcesReadRequestHandler());
|
||||
}
|
||||
|
||||
// Add resource templates API handlers if provided.
|
||||
if (!Utils.isEmpty(this.resourceTemplates)) {
|
||||
requestHandlers.put("resources/templates/list", resourceTemplateListRequestHandler());
|
||||
requestHandlers.put(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, resourceTemplateListRequestHandler());
|
||||
}
|
||||
|
||||
// Add prompts API handlers if provider exists
|
||||
if (!Utils.isEmpty(this.prompts)) {
|
||||
requestHandlers.put("prompts/list", promptsListRequestHandler());
|
||||
requestHandlers.put("prompts/get", promptsGetRequestHandler());
|
||||
requestHandlers.put(McpSchema.METHOD_PROMPT_LIST, promptsListRequestHandler());
|
||||
requestHandlers.put(McpSchema.METHOD_PROMPT_GET, promptsGetRequestHandler());
|
||||
}
|
||||
|
||||
// Add logging API handlers if the logging capability is enabled
|
||||
if (this.serverCapabilities.logging() != null) {
|
||||
requestHandlers.put(McpSchema.METHOD_LOGGING_SET_LEVEL, setLoggerRequestHandler());
|
||||
}
|
||||
|
||||
Map<String, NotificationHandler> notificationHandlers = new HashMap<>();
|
||||
|
||||
notificationHandlers.put("notifications/initialized", (params) -> Mono.empty());
|
||||
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_INITIALIZED, (params) -> Mono.empty());
|
||||
|
||||
this.transport = mcpTransport;
|
||||
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
|
||||
*/
|
||||
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() {
|
||||
return params -> {
|
||||
McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
|
||||
new TypeReference<McpSchema.PaginatedRequest>() {
|
||||
});
|
||||
|
||||
List<Tool> tools = this.tools.stream().map(toolRegistration -> {
|
||||
return toolRegistration.tool();
|
||||
}).toList();
|
||||
|
||||
logger.info("Client tools list request - Cursor: {}", request.cursor());
|
||||
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
|
||||
*/
|
||||
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() {
|
||||
|
|
@ -428,14 +451,15 @@ public class McpAsyncServer {
|
|||
* @return A Mono that completes when all clients have been notified
|
||||
*/
|
||||
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() {
|
||||
return params -> {
|
||||
McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
|
||||
new TypeReference<McpSchema.PaginatedRequest>() {
|
||||
});
|
||||
// TODO: Implement pagination
|
||||
// McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
|
||||
// new TypeReference<McpSchema.PaginatedRequest>() {
|
||||
// });
|
||||
|
||||
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;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.ai.mcp.server.McpServer.PromptRegistration;
|
||||
import org.springframework.ai.mcp.server.McpServer.ResourceRegistration;
|
||||
import org.springframework.ai.mcp.server.McpServer.ToolRegistration;
|
||||
import org.springframework.ai.mcp.spec.McpSchema.LoggingMessageNotification;
|
||||
import org.springframework.ai.mcp.util.Assert;
|
||||
|
||||
/**
|
||||
|
|
@ -113,6 +116,14 @@ public class McpSyncServer {
|
|||
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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -44,6 +44,57 @@ public class McpSchema {
|
|||
|
||||
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
|
||||
// ---------------------------
|
||||
|
|
@ -254,11 +305,16 @@ public class McpSchema {
|
|||
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
||||
public record ServerCapabilities( // @formatter:off
|
||||
@JsonProperty("experimental") Map<String, Object> experimental,
|
||||
@JsonProperty("logging") Object logging,
|
||||
@JsonProperty("logging") LoggingCapabilities logging,
|
||||
@JsonProperty("prompts") PromptCapabilities prompts,
|
||||
@JsonProperty("resources") ResourceCapabilities resources,
|
||||
@JsonProperty("tools") ToolCapabilities tools) {
|
||||
|
||||
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
||||
public record LoggingCapabilities() {
|
||||
}
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
||||
public record PromptCapabilities(
|
||||
@JsonProperty("listChanged") Boolean listChanged) {
|
||||
|
|
@ -282,7 +338,7 @@ public class McpSchema {
|
|||
public static class Builder {
|
||||
|
||||
private Map<String, Object> experimental;
|
||||
private Object logging;
|
||||
private LoggingCapabilities logging = new LoggingCapabilities();
|
||||
private PromptCapabilities prompts;
|
||||
private ResourceCapabilities resources;
|
||||
private ToolCapabilities tools;
|
||||
|
|
@ -292,8 +348,8 @@ public class McpSchema {
|
|||
return this;
|
||||
}
|
||||
|
||||
public Builder logging(Object logging) {
|
||||
this.logging = logging;
|
||||
public Builder logging() {
|
||||
this.logging = new LoggingCapabilities();
|
||||
return this;
|
||||
}
|
||||
|
||||
|
|
@ -331,19 +387,6 @@ public class McpSchema {
|
|||
@JsonProperty("assistant") ASSISTANT
|
||||
}// @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
|
||||
// ---------------------------
|
||||
|
|
@ -752,12 +795,73 @@ public class McpSchema {
|
|||
@JsonProperty("total") Double total) {
|
||||
}// @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
|
||||
@JsonProperty("level") LoggingLevel level,
|
||||
@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
|
||||
|
||||
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
|
||||
// ---------------------------
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import org.junit.jupiter.api.Disabled;
|
|||
import org.junit.jupiter.api.Test;
|
||||
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.ClientCapabilities;
|
||||
import org.springframework.ai.mcp.spec.McpSchema.GetPromptRequest;
|
||||
|
|
@ -322,4 +323,38 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
}).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.Test;
|
||||
|
||||
import org.springframework.ai.mcp.spec.McpSchema;
|
||||
import org.springframework.ai.mcp.spec.McpSchema.CallToolRequest;
|
||||
import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
|
||||
import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities;
|
||||
|
|
@ -277,4 +278,38 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
}).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
|
||||
McpSchema.JSONRPCNotification notification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION,
|
||||
"notifications/tools/list_changed", null);
|
||||
McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED, null);
|
||||
transport.simulateIncomingMessage(notification);
|
||||
|
||||
// Simulate server response to tools/list request
|
||||
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,
|
||||
toolsListRequest.id(), mockToolsResult, null);
|
||||
|
|
@ -166,8 +166,8 @@ class McpAsyncClientResponseHandlerTests {
|
|||
.async();
|
||||
|
||||
// Simulate incoming request
|
||||
McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, "roots/list",
|
||||
"test-id", null);
|
||||
McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION,
|
||||
McpSchema.METHOD_ROOTS_LIST, "test-id", null);
|
||||
transport.simulateIncomingMessage(request);
|
||||
|
||||
// Verify response
|
||||
|
|
@ -207,12 +207,12 @@ class McpAsyncClientResponseHandlerTests {
|
|||
|
||||
// Simulate server sending resources/list_changed notification
|
||||
McpSchema.JSONRPCNotification notification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION,
|
||||
"notifications/resources/list_changed", null);
|
||||
McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED, null);
|
||||
transport.simulateIncomingMessage(notification);
|
||||
|
||||
// Simulate server response to resources/list request
|
||||
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,
|
||||
resourcesListRequest.id(), mockResourcesResult, null);
|
||||
|
|
@ -251,12 +251,12 @@ class McpAsyncClientResponseHandlerTests {
|
|||
|
||||
// Simulate server sending prompts/list_changed notification
|
||||
McpSchema.JSONRPCNotification notification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION,
|
||||
"notifications/prompts/list_changed", null);
|
||||
McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED, null);
|
||||
transport.simulateIncomingMessage(notification);
|
||||
|
||||
// Simulate server response to prompts/list request
|
||||
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,
|
||||
promptsListRequest.id(), mockPromptsResult, null);
|
||||
|
|
@ -303,7 +303,7 @@ class McpAsyncClientResponseHandlerTests {
|
|||
|
||||
// Simulate incoming request
|
||||
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);
|
||||
|
||||
// Verify response
|
||||
|
|
@ -342,7 +342,7 @@ class McpAsyncClientResponseHandlerTests {
|
|||
|
||||
// Simulate incoming request
|
||||
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);
|
||||
|
||||
// Verify error response
|
||||
|
|
@ -374,7 +374,7 @@ class McpAsyncClientResponseHandlerTests {
|
|||
|
||||
// Simulate incoming request
|
||||
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);
|
||||
|
||||
// Verify error response
|
||||
|
|
|
|||
|
|
@ -75,6 +75,10 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
onClose();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Server Lifecycle Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testConstructorWithInvalidArguments() {
|
||||
assertThatThrownBy(() -> McpServer.using(null)).isInstanceOf(IllegalArgumentException.class)
|
||||
|
|
@ -85,6 +89,24 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
.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
|
||||
void testAddTool() {
|
||||
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)));
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Resources Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testNotifyResourcesListChanged() {
|
||||
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)));
|
||||
}
|
||||
|
||||
@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
|
||||
void testAddResource() {
|
||||
var mcpAsyncServer = McpServer.using(createMcpTransport())
|
||||
|
|
@ -216,7 +219,6 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
|
||||
@Test
|
||||
void testAddResourceWithNullRegistration() {
|
||||
|
||||
var mcpAsyncServer = McpServer.using(createMcpTransport())
|
||||
.info("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().resources(true, false).build())
|
||||
|
|
@ -229,18 +231,6 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
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
|
||||
void testAddResourceWithoutCapability() {
|
||||
// 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
|
||||
void testAddPromptWithoutCapability() {
|
||||
// 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
|
||||
void testRemovePromptWithoutCapability() {
|
||||
// Create a server without prompt capabilities
|
||||
|
|
@ -339,4 +354,53 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
.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();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Server Lifecycle Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testConstructorWithInvalidArguments() {
|
||||
assertThatThrownBy(() -> McpServer.using(null)).isInstanceOf(IllegalArgumentException.class)
|
||||
|
|
@ -84,6 +88,33 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
.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
|
||||
void testAddTool() {
|
||||
var mcpSyncServer = McpServer.using(createMcpTransport())
|
||||
|
|
@ -154,6 +185,10 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Resources Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testNotifyResourcesListChanged() {
|
||||
var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
|
||||
|
|
@ -163,29 +198,6 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
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
|
||||
void testAddResource() {
|
||||
var mcpSyncServer = McpServer.using(createMcpTransport())
|
||||
|
|
@ -216,17 +228,6 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
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
|
||||
void testAddResourceWithoutCapability() {
|
||||
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");
|
||||
}
|
||||
|
||||
@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
|
||||
void testAddPromptWithoutCapability() {
|
||||
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");
|
||||
}
|
||||
|
||||
@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
|
||||
void testRemovePromptWithoutCapability() {
|
||||
var serverWithoutPrompts = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
|
||||
|
|
@ -271,7 +296,6 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
|
||||
@Test
|
||||
void testRemovePrompt() {
|
||||
|
||||
Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of());
|
||||
PromptRegistration registration = new PromptRegistration(prompt, req -> new GetPromptResult(
|
||||
"Test prompt description",
|
||||
|
|
@ -282,8 +306,6 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
.capabilities(ServerCapabilities.builder().prompts(true).build())
|
||||
.prompts(registration)
|
||||
.sync();
|
||||
// assertThatCode(() ->
|
||||
// mcpSyncServer.addPrompt(registration)).doesNotThrowAnyException();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.removePrompt(TEST_PROMPT_NAME)).doesNotThrowAnyException();
|
||||
|
||||
|
|
@ -303,13 +325,53 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Logging Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testGetAsyncServer() {
|
||||
var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
|
||||
void testLoggingLevels() {
|
||||
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 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.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.CallToolResult;
|
||||
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.Role;
|
||||
import org.springframework.ai.mcp.spec.McpSchema.TextContent;
|
||||
|
|
@ -27,6 +30,8 @@ import org.springframework.web.reactive.function.server.RouterFunction;
|
|||
@Configuration
|
||||
public class McpServerConfig {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(McpServerConfig.class);
|
||||
|
||||
// STDIO transport
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "stdio")
|
||||
|
|
@ -41,7 +46,8 @@ public class McpServerConfig {
|
|||
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
|
||||
@ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "sse")
|
||||
public RouterFunction<?> mcpRouterFunction(SseServerTransport transport) {
|
||||
|
|
@ -56,16 +62,20 @@ public class McpServerConfig {
|
|||
.resources(false, true) // No subscribe support, but list changes notifications
|
||||
.tools(true) // Tool support with list changes notifications
|
||||
.prompts(true) // Prompt support with list changes notifications
|
||||
.logging() // Logging support
|
||||
.build();
|
||||
|
||||
// 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")
|
||||
.capabilities(capabilities)
|
||||
.resources(systemInfoResourceRegistration())
|
||||
.prompts(greetingPromptRegistration())
|
||||
.tools(weatherToolRegistration(), calculatorToolRegistration())
|
||||
.tools(calculatorToolRegistration())
|
||||
.async();
|
||||
|
||||
server.addTool(weatherToolRegistration(server));
|
||||
return server; // @formatter:on
|
||||
} // @formatter:on
|
||||
|
||||
private static ResourceRegistration systemInfoResourceRegistration() {
|
||||
|
|
@ -120,13 +130,27 @@ public class McpServerConfig {
|
|||
});
|
||||
}
|
||||
|
||||
private static ToolRegistration weatherToolRegistration() {
|
||||
private static ToolRegistration weatherToolRegistration(McpAsyncServer server) {
|
||||
return new ToolRegistration(
|
||||
new McpSchema.Tool("weather", "Weather forecast tool by location", Map.of("city", "String")),
|
||||
(arguments) -> {
|
||||
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
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
public class McpFunctionCallback implements FunctionCallback {
|
||||
|
|
|
|||
Loading…
Reference in a new issue