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:
Christian Tzolov 2024-12-30 23:42:18 +01:00
commit 86659344fc
16 changed files with 861 additions and 228 deletions

View file

@ -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).

View file

@ -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

View file

@ -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(

View file

@ -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,11 +441,7 @@ 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
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) { for (Consumer<List<McpSchema.Tool>> toolsChangeConsumer : toolsChangeConsumers) {
toolsChangeConsumer.accept(listToolsResult.tools()); toolsChangeConsumer.accept(listToolsResult.tools());
} }
@ -439,8 +449,6 @@ public class McpAsyncClient {
logger.error("Error handling tools list change notification", error); logger.error("Error handling tools list change notification", error);
return Mono.empty(); return Mono.empty();
}).then(); // Convert to Mono<Void> }).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
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) { for (Consumer<List<McpSchema.Resource>> resourceChangeConsumer : resourcesChangeConsumers) {
resourceChangeConsumer.accept(listResourcesResult.resources()); resourceChangeConsumer.accept(listResourcesResult.resources());
} }
}).subscribeOn(Schedulers.boundedElastic())).onErrorResume(error -> { }).subscribeOn(Schedulers.boundedElastic())).onErrorResume(error -> {
logger.error("Error handling resources list change notification", error); logger.error("Error handling resources list change notification", error);
return Mono.empty(); return Mono.empty();
}).then(); // Convert to Mono<Void> }).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,16 +615,13 @@ 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
public Mono<Void> handle(Object params) {
// TODO: add support for cursor/pagination
return listPrompts().flatMap(listPromptsResult -> Mono.fromRunnable(() -> { return listPrompts().flatMap(listPromptsResult -> Mono.fromRunnable(() -> {
for (Consumer<List<McpSchema.Prompt>> promptChangeConsumer : promptsChangeConsumers) { for (Consumer<List<McpSchema.Prompt>> promptChangeConsumer : promptsChangeConsumers) {
promptChangeConsumer.accept(listPromptsResult.prompts()); promptChangeConsumer.accept(listPromptsResult.prompts());
@ -628,8 +630,51 @@ public class McpAsyncClient {
logger.error("Error handling prompts list change notification", error); logger.error("Error handling prompts list change notification", error);
return Mono.empty(); return Mono.empty();
}).then(); // Convert to Mono<Void> }).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);
}
} }

View file

@ -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);
} }
} }

View file

@ -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();
}
} }

View file

@ -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();
};
}
} }

View file

@ -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.
*/ */

View file

@ -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,10 +795,71 @@ 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
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 } // @formatter:on
// --------------------------- // ---------------------------

View file

@ -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");
}
} }

View file

@ -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");
}
} }

View file

@ -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

View file

@ -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);
}
} }

View file

@ -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);
} }
} }

View file

@ -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;
}); });
} }

View file

@ -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 {