diff --git a/README.md b/README.md index 6b4ef27..a6b72e5 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/ref-server.md b/docs/ref-server.md index 6daf3e9..5ee6739 100644 --- a/docs/ref-server.md +++ b/docs/ref-server.md @@ -136,10 +136,6 @@ McpServer.using(transport) .sync(); ``` -### Implementation Notes - -- The server factory uses a builder pattern for configuration -- Default server info is "mcp-server" version "1.0.0" if not specified - Tool handlers must be thread-safe as they may be called concurrently - The synchronous server is implemented as a wrapper around the asynchronous server - Resource and prompt providers use cursor-based pagination @@ -160,3 +156,59 @@ Tool handlers implement this interface to define: - Tool name and description - Input schema for validation - Execution logic in the call method + +### Logging Capabilities + +The MCP Server supports logging functionality that allows sending log messages with different severity levels to the Client. This feature can be enabled through server capabilities configuration. + +#### Enabling Logging + +```java +McpServer.using(transport) + .serverInfo("my-server", "1.0.0") + .capabilities(ServerCapabilities.builder().logging().build()) + .sync(); // or .async() +``` + +#### Logging Levels + +The server supports the following logging levels: +- TRACE +- DEBUG +- INFO +- WARN +- ERROR +- FATAL + +#### Sending Log Messages + +For synchronous servers: +```java +LoggingMessageNotification notification = McpSchema.LoggingMessageNotification.builder() + .level(McpSchema.LoggingLevel.INFO) + .logger("my-logger") + .data("Log message") + .build(); + +mcpSyncServer.loggingNotification(notification); +``` + +For asynchronous servers: +```java +LoggingMessageNotification notification = McpSchema.LoggingMessageNotification.builder() + .level(McpSchema.LoggingLevel.INFO) + .logger("my-logger") + .data("Log message") + .build(); + +mcpAsyncServer.loggingNotification(notification) + .subscribe(); +``` + +- Logging works even if the logging capability is not enabled +- Null notifications are rejected with a McpError +- Log messages include: + - Level: The severity level of the message + - Logger: The name of the logger (typically identifies the source) + - Data: The actual log message content +- The server implementation is transport-agnostic, allowing different transport mechanisms to handle the log messages diff --git a/mcp/README.md b/mcp/README.md index 4c9c641..f51b0be 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -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 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( diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java b/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java index add1ad6..3931c77 100644 --- a/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java +++ b/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java @@ -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>> toolsChangeConsumers, List>> resourcesChangeConsumers, List>> promptsChangeConsumers, + List> loggingConsumers, Function 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> 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 result = this.mcpSession.sendRequest("initialize", initializeRequest, - new TypeReference() { + Mono result = this.mcpSession.sendRequest(McpSchema.METHOD_INITIALIZE, + initializeRequest, new TypeReference() { }); 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 ping() { - return this.mcpSession.sendRequest("ping", null, new TypeReference() { + return this.mcpSession.sendRequest(McpSchema.METHOD_PING, null, new TypeReference() { }); } @@ -335,7 +349,7 @@ public class McpAsyncClient { * @return A Mono that completes when the notification is sent */ public Mono 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 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 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>> toolsChangeConsumers) { - return new NotificationHandler() { - @Override - public Mono handle(Object params) { - // TODO: add support for cursor/pagination - return listTools().flatMap(listToolsResult -> Mono.fromRunnable(() -> { - for (Consumer> 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 + return params -> listTools().flatMap(listToolsResult -> Mono.fromRunnable(() -> { + for (Consumer> 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 }; // -------------------------- @@ -470,7 +478,7 @@ public class McpAsyncClient { * @return A Mono that completes with the list of resources result */ public Mono 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 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 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 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 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 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>> resourcesChangeConsumers) { - return new NotificationHandler() { - @Override - public Mono handle(Object params) { - // TODO: add support for cursor/pagination - return listResources().flatMap(listResourcesResult -> Mono.fromRunnable(() -> { - for (Consumer> 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 + return params -> listResources().flatMap(listResourcesResult -> Mono.fromRunnable(() -> { + for (Consumer> 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 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 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 promptListChangedNotification() { - return this.mcpSession.sendNotification("notifications/prompts/list_changed"); + return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED); } private NotificationHandler promptsChangeNotificationHandler( List>> promptsChangeConsumers) { - return new NotificationHandler() { - @Override - public Mono handle(Object params) { - // TODO: add support for cursor/pagination - return listPrompts().flatMap(listPromptsResult -> Mono.fromRunnable(() -> { - for (Consumer> 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 - } - }; + return params -> {// @formatter:off + return listPrompts().flatMap(listPromptsResult -> Mono.fromRunnable(() -> { + for (Consumer> 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 + }; // @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> loggingConsumers) { + + return params -> { + + McpSchema.LoggingMessageNotification loggingMessageNotification = transport.unmarshalFrom(params, + new TypeReference() { + }); + + return Mono.fromRunnable(() -> { + for (Consumer 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 setLoggingLevel(LoggingLevel loggingLevel) { + Assert.notNull(loggingLevel, "Logging level must not be null"); + + String levelName = this.transport.unmarshalFrom(loggingLevel, new TypeReference() { + }); + + Map params = Map.of("level", levelName); + + return this.mcpSession.sendNotification(McpSchema.METHOD_LOGGING_SET_LEVEL, params); + } + } diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java b/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java index bea9478..dcd8bd0 100644 --- a/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java +++ b/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java @@ -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(); * } * @@ -82,6 +85,16 @@ import org.springframework.ai.mcp.util.Assert; *
  • Prompt template handling *
  • Real-time updates through change consumers *
  • Custom sampling strategies + *
  • Structured logging with severity levels + * + * + *

    + * The client supports structured logging through the MCP logging utility: + *

      + *
    • Eight severity levels from DEBUG to EMERGENCY + *
    • Optional logger name categorization + *
    • Configurable logging consumers + *
    • Server-controlled minimum log level *
    * * @author Christian Tzolov @@ -141,6 +154,8 @@ public interface McpClient { private List>> promptsChangeConsumers = new ArrayList<>(); + private List> loggingConsumers = new ArrayList<>(); + private Function 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 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> 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); } } diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java b/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java index 88b87e0..866e4d2 100644 --- a/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java +++ b/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java @@ -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(); + } + } diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java b/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java index ccd32c4..75d68e7 100644 --- a/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java +++ b/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java @@ -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 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 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.just("")); + requestHandlers.put(McpSchema.METHOD_PING, (params) -> Mono.just("")); // Add tools API handlers if the tool capability is enabled if (this.serverCapabilities.tools() != null) { - requestHandlers.put("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 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 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() { - }); List 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 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 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() { - }); + // TODO: Implement pagination + // McpSchema.PaginatedRequest request = transport.unmarshalFrom(params, + // new TypeReference() { + // }); 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 loggingNotification(LoggingMessageNotification loggingMessageNotification) { + + if (loggingMessageNotification == null) { + return Mono.error(new McpError("Logging message must not be null")); + } + + Map params = this.transport.unmarshalFrom(loggingMessageNotification, + new TypeReference>() { + }); + + 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() { + }); + + this.minLoggingLevel = setLoggerRequest; + + return Mono.empty(); + }; + } + } diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/McpSyncServer.java b/mcp/src/main/java/org/springframework/ai/mcp/server/McpSyncServer.java index 2650c9d..97f6b9e 100644 --- a/mcp/src/main/java/org/springframework/ai/mcp/server/McpSyncServer.java +++ b/mcp/src/main/java/org/springframework/ai/mcp/server/McpSyncServer.java @@ -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. */ diff --git a/mcp/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java b/mcp/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java index 2c466d6..663cc0c 100644 --- a/mcp/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java +++ b/mcp/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java @@ -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 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 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 // --------------------------- diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java b/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java index b7b26a2..c8d357d 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java +++ b/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java @@ -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"); + } + } diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java b/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java index c55d775..7a1939b 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java +++ b/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java @@ -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"); + } + } diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java index 282744c..68bbc32 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java +++ b/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java @@ -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 diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpAsyncServerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpAsyncServerTests.java index 3799a39..6de166e 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpAsyncServerTests.java +++ b/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpAsyncServerTests.java @@ -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); + } + } diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java index 8807d40..04fa9a3 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java +++ b/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java @@ -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); } } diff --git a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerConfig.java b/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerConfig.java index 06d2dc3..8d919b8 100644 --- a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerConfig.java +++ b/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerConfig.java @@ -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; }); } diff --git a/spring-ai-mcp/src/main/java/org/springframework/ai/mcp/spring/McpFunctionCallback.java b/spring-ai-mcp/src/main/java/org/springframework/ai/mcp/spring/McpFunctionCallback.java index 8f965b5..e06b026 100644 --- a/spring-ai-mcp/src/main/java/org/springframework/ai/mcp/spring/McpFunctionCallback.java +++ b/spring-ai-mcp/src/main/java/org/springframework/ai/mcp/spring/McpFunctionCallback.java @@ -28,7 +28,6 @@ import org.springframework.ai.model.function.FunctionCallback; /** * @author Christian Tzolov - * @since 1.0.0 */ public class McpFunctionCallback implements FunctionCallback {