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

View file

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

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -28,7 +28,6 @@ import org.springframework.ai.model.function.FunctionCallback;
/**
* @author Christian Tzolov
* @since 1.0.0
*/
public class McpFunctionCallback implements FunctionCallback {