From aa26cfa848b47470fe2829d31fb247857fc7fab2 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Mon, 6 Jan 2025 21:29:06 +0100 Subject: [PATCH] feat: Improve Tool schema handling - Make McpSchema final and add private constructor - Add JsonSchema record for structured schema representation - Change Tool.inputSchema type from Map to JsonSchema. - Use the String JSON Schema constructor when creating the Tool programaticaly. - Enhance StdioClientTransport error logging with more context - Add ToolHelper utility class for: - Converting Spring AI FunctionCallbacks to MCP tools - Generating JSON schemas for tool input validation - Facilitating integration between Spring AI functions and MCP tools - Update tests and samples to use the new Tool schema format Resolves #52 --- .../transport/StdioClientTransport.java | 4 +- .../ai/mcp/spec/McpSchema.java | 46 +++-- .../ai/mcp/attic/DemoServer.java | 2 +- .../McpAsyncClientResponseHandlerTests.java | 7 +- .../server/AbstractMcpAsyncServerTests.java | 15 +- .../server/AbstractMcpSyncServerTests.java | 14 +- .../mcp/server/SseAsyncIntegrationTests.java | 22 ++- spring-ai-mcp-sample/pom.xml | 2 +- .../ai/mcp/sample/client/SampleClient.java | 7 + .../ai/mcp/sample/server/McpServerConfig.java | 67 ++++++- .../ai/mcp/sample/server/OpenLibrary.java | 74 ++++++++ .../src/main/resources/application.properties | 18 +- .../ai/mcp/spring/ToolHelper.java | 167 ++++++++++++++++++ 13 files changed, 393 insertions(+), 52 deletions(-) create mode 100644 spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/OpenLibrary.java create mode 100644 spring-ai-mcp/src/main/java/org/springframework/ai/mcp/spring/ToolHelper.java diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/transport/StdioClientTransport.java b/mcp/src/main/java/org/springframework/ai/mcp/client/transport/StdioClientTransport.java index e9aac82..74d001a 100644 --- a/mcp/src/main/java/org/springframework/ai/mcp/client/transport/StdioClientTransport.java +++ b/mcp/src/main/java/org/springframework/ai/mcp/client/transport/StdioClientTransport.java @@ -275,14 +275,14 @@ public class StdioClientTransport implements ClientMcpTransport { JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.objectMapper, line); if (!this.inboundSink.tryEmitNext(message).isSuccess()) { if (!isClosing) { - logger.error("Failed to enqueue inbound message"); + logger.error("Failed to enqueue inbound message: {}", message); } break; } } catch (Exception e) { if (!isClosing) { - logger.error("Error processing inbound message", e); + logger.error("Error processing inbound message for line: " + line, e); } break; } 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 bb3b301..ce274f8 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 @@ -38,7 +38,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; * * @author Christian Tzolov */ -public class McpSchema { +public final class McpSchema { + + private McpSchema() { + } public static final String LATEST_PROTOCOL_VERSION = "2024-11-05"; @@ -95,6 +98,8 @@ public class McpSchema { // Sampling Methods public static final String METHOD_SAMPLING_CREATE_MESSAGE = "sampling/createMessage"; + private static ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + // --------------------------- // JSON-RPC Error Codes // --------------------------- @@ -659,6 +664,15 @@ public class McpSchema { @JsonProperty("nextCursor") String nextCursor) { }// @formatter:on + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + record JsonSchema( // @formatter:off + @JsonProperty("type") String type, + @JsonProperty("properties") Map properties, + @JsonProperty("required") List required, + @JsonProperty("additionalProperties") Boolean additionalProperties) { + } // @formatter:on + /** * Represents a tool that the server provides. Tools enable servers to expose * executable functionality to the system. Through these tools, you can interact with @@ -676,7 +690,7 @@ public class McpSchema { public record Tool( // @formatter:off @JsonProperty("name") String name, @JsonProperty("description") String description, - @JsonProperty("inputSchema") Map inputSchema) { + @JsonProperty("inputSchema") JsonSchema inputSchema) { public Tool(String name, String description, String schema) { this(name, description, parseSchema(schema)); @@ -684,9 +698,9 @@ public class McpSchema { } // @formatter:on - public static Map parseSchema(String schema) { + public static JsonSchema parseSchema(String schema) { try { - return new ObjectMapper().readValue(schema, MAP_TYPE_REF); + return OBJECT_MAPPER.readValue(schema, JsonSchema.class); } catch (IOException e) { throw new IllegalArgumentException("Invalid schema: " + schema, e); @@ -724,6 +738,18 @@ public class McpSchema { // --------------------------- // Sampling Interfaces // --------------------------- + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record ModelPreferences(// @formatter:off + @JsonProperty("hints") List hints, + @JsonProperty("costPriority") Double costPriority, + @JsonProperty("speedPriority") Double speedPriority, + @JsonProperty("intelligencePriority") Double intelligencePriority) { + } // @formatter:on + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record ModelHint(@JsonProperty("name") String name) { + } + @JsonInclude(JsonInclude.Include.NON_ABSENT) public record SamplingMessage(// @formatter:off @JsonProperty("role") Role role, @@ -803,18 +829,6 @@ public class McpSchema { } }// @formatter:on - // --------------------------- - // Model Preferences - // --------------------------- - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record ModelPreferences(List hints, Double costPriority, Double speedPriority, - Double intelligencePriority) { - } - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record ModelHint(String name) { - } - // --------------------------- // Pagination Interfaces // --------------------------- diff --git a/mcp/src/test/java/org/springframework/ai/mcp/attic/DemoServer.java b/mcp/src/test/java/org/springframework/ai/mcp/attic/DemoServer.java index d663f14..df1fc0b 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/attic/DemoServer.java +++ b/mcp/src/test/java/org/springframework/ai/mcp/attic/DemoServer.java @@ -44,7 +44,7 @@ public class DemoServer { var mcpServer = McpServer.using(transport) .serverInfo("Weather Forecast", "1.0.0") - .tool(new McpSchema.Tool("weather", "Weather forecast tool by location", Map.of("city", "String")), + .tool(new McpSchema.Tool("weather", "Weather forecast tool by location", "{ \"type\": \"object\" }"), (arguments) -> { String city = (String) arguments.get("city"); return new CallToolResult(List.of(), false); 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 81e0fd2..6c67b99 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 @@ -23,7 +23,9 @@ import java.util.Map; import java.util.function.Consumer; import java.util.function.Function; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.ai.mcp.MockMcpTransport; @@ -39,7 +41,7 @@ import static org.awaitility.Awaitility.await; class McpAsyncClientResponseHandlerTests { @Test - void testToolsChangeNotificationHandling() { + void testToolsChangeNotificationHandling() throws JsonProcessingException { MockMcpTransport transport = new MockMcpTransport(); // Create a list to store received tools for verification @@ -55,7 +57,8 @@ class McpAsyncClientResponseHandlerTests { // Create a mock tools list that the server will return Map inputSchema = Map.of("type", "object", "properties", Map.of(), "required", List.of()); - McpSchema.Tool mockTool = new McpSchema.Tool("test-tool", "Test Tool Description", inputSchema); + McpSchema.Tool mockTool = new McpSchema.Tool("test-tool", "Test Tool Description", + new ObjectMapper().writeValueAsString(inputSchema)); McpSchema.ListToolsResult mockToolsResult = new McpSchema.ListToolsResult(List.of(mockTool), null); // Simulate server sending tools/list_changed notification 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 3ecd854..e51130f 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 @@ -107,10 +107,17 @@ public abstract class AbstractMcpAsyncServerTests { // --------------------------------------- // Tools Tests // --------------------------------------- + String emptyJsonSchema = """ + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + } + """; @Test void testAddTool() { - Tool newTool = new McpSchema.Tool("new-tool", "New test tool", Map.of("input", "string")); + Tool newTool = new McpSchema.Tool("new-tool", "New test tool", emptyJsonSchema); var mcpAsyncServer = McpServer.using(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) @@ -125,7 +132,7 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testAddDuplicateTool() { - Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", Map.of("input", "string")); + Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema); var mcpAsyncServer = McpServer.using(createMcpTransport()) .serverInfo("test-server", "1.0.0") @@ -146,7 +153,7 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testRemoveTool() { - Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", Map.of("input", "string")); + Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema); var mcpAsyncServer = McpServer.using(createMcpTransport()) .serverInfo("test-server", "1.0.0") @@ -175,7 +182,7 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testNotifyToolsListChanged() { - Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", Map.of("input", "string")); + Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema); var mcpAsyncServer = McpServer.using(createMcpTransport()) .serverInfo("test-server", "1.0.0") 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 e9aa970..73dcc40 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 @@ -116,6 +116,14 @@ public abstract class AbstractMcpSyncServerTests { // Tools Tests // --------------------------------------- + String emptyJsonSchema = """ + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + } + """; + @Test void testAddTool() { var mcpSyncServer = McpServer.using(createMcpTransport()) @@ -123,7 +131,7 @@ public abstract class AbstractMcpSyncServerTests { .capabilities(ServerCapabilities.builder().tools(true).build()) .sync(); - Tool newTool = new McpSchema.Tool("new-tool", "New test tool", Map.of("input", "string")); + Tool newTool = new McpSchema.Tool("new-tool", "New test tool", emptyJsonSchema); assertThatCode(() -> mcpSyncServer .addTool(new ToolRegistration(newTool, args -> new CallToolResult(List.of(), false)))) .doesNotThrowAnyException(); @@ -133,7 +141,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testAddDuplicateTool() { - Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", Map.of("input", "string")); + Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema); var mcpSyncServer = McpServer.using(createMcpTransport()) .serverInfo("test-server", "1.0.0") @@ -151,7 +159,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testRemoveTool() { - Tool tool = new McpSchema.Tool(TEST_TOOL_NAME, "Test tool", Map.of("input", "string")); + Tool tool = new McpSchema.Tool(TEST_TOOL_NAME, "Test tool", emptyJsonSchema); var mcpSyncServer = McpServer.using(createMcpTransport()) .serverInfo("test-server", "1.0.0") diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/SseAsyncIntegrationTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/SseAsyncIntegrationTests.java index 22e32e7..babba8d 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/server/SseAsyncIntegrationTests.java +++ b/mcp/src/test/java/org/springframework/ai/mcp/server/SseAsyncIntegrationTests.java @@ -322,12 +322,22 @@ public class SseAsyncIntegrationTests { // --------------------------------------- // Tools Tests // --------------------------------------- + + String emptyJsonSchema = """ + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + } + """; + @Test + void testToolCallSuccess() { var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null); - ToolRegistration tool1 = new ToolRegistration( - new McpSchema.Tool("tool1", "tool1 description", Map.of("city", "String")), request -> { + ToolRegistration tool1 = new ToolRegistration(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), + request -> { // perform a blocking call to a remote service String response = RestClient.create() .get() @@ -363,8 +373,8 @@ public class SseAsyncIntegrationTests { void testToolListChangeHandlingSuccess() { var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null); - ToolRegistration tool1 = new ToolRegistration( - new McpSchema.Tool("tool1", "tool1 description", Map.of("city", "String")), request -> { + ToolRegistration tool1 = new ToolRegistration(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), + request -> { // perform a blocking call to a remote service String response = RestClient.create() .get() @@ -413,8 +423,8 @@ public class SseAsyncIntegrationTests { }); // Add a new tool - ToolRegistration tool2 = new ToolRegistration( - new McpSchema.Tool("tool2", "tool2 description", Map.of("city", "String")), request -> callResponse); + ToolRegistration tool2 = new ToolRegistration(new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema), + request -> callResponse); mcpServer.addTool(tool2); diff --git a/spring-ai-mcp-sample/pom.xml b/spring-ai-mcp-sample/pom.xml index fe0cf97..edc4671 100644 --- a/spring-ai-mcp-sample/pom.xml +++ b/spring-ai-mcp-sample/pom.xml @@ -20,7 +20,7 @@ org.springframework.experimental - mcp + spring-ai-mcp ${project.version} diff --git a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/SampleClient.java b/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/SampleClient.java index c1af31d..4907e5d 100644 --- a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/SampleClient.java +++ b/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/SampleClient.java @@ -58,6 +58,13 @@ public class SampleClient { .callTool(new CallToolRequest("calculator", Map.of("operation", "multiply", "a", 2.0, "b", 3.0))); System.out.println("Calculator Response = " + calcResponse); + CallToolResult paymentStatus = client.callTool( + new CallToolRequest("paymentTransactionStatus", Map.of("transactionId", "006", "accountName", "John"))); + System.out.println("Payment Status Response = " + paymentStatus); + + CallToolResult parks = client.callTool(new CallToolRequest("getBooks", Map.of("title", "Spring Framework"))); + System.out.println("Books Response = " + parks); + // List and demonstrate resources var resourcesList = client.listResources(); System.out.println("\nAvailable Resources = " + resourcesList); 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 eee55a0..08deb08 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 @@ -2,6 +2,7 @@ package org.springframework.ai.mcp.sample.server; import java.util.List; import java.util.Map; +import java.util.function.Function; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; @@ -21,10 +22,14 @@ 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; +import org.springframework.ai.mcp.spec.McpSchema.Tool; import org.springframework.ai.mcp.spec.ServerMcpTransport; +import org.springframework.ai.mcp.spring.ToolHelper; +import org.springframework.ai.model.function.FunctionCallback; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; import org.springframework.web.reactive.function.server.RouterFunction; @Configuration @@ -55,7 +60,7 @@ public class McpServerConfig { } @Bean - public McpAsyncServer mcpServer(ServerMcpTransport transport) { // @formatter:off + public McpAsyncServer mcpServer(ServerMcpTransport transport, OpenLibrary openLibrary) { // @formatter:off // Configure server capabilities with resource support var capabilities = McpSchema.ServerCapabilities.builder() @@ -71,13 +76,48 @@ public class McpServerConfig { .capabilities(capabilities) .resources(systemInfoResourceRegistration()) .prompts(greetingPromptRegistration()) - .tools(calculatorToolRegistration()) + .tools(calculatorToolRegistration(), + ToolHelper.toToolRegistration( + FunctionCallback.builder() + .description("Get transaction payment status") + .method("paymentTransactionStatus",String.class, String.class) + .targetClass(McpServerConfig.class) + .build()), + ToolHelper.toToolRegistration( + FunctionCallback.builder() + .description("To upper case") + .function("toUpperCase", new Function() { + @Override + public String apply(String s) { + return s.toUpperCase(); + } + }) + .inputType(String.class) + .build())) + .tools(openLibraryToolRegistrations(openLibrary)) .async(); server.addTool(weatherToolRegistration(server)); return server; // @formatter:on } // @formatter:on + public static List openLibraryToolRegistrations(OpenLibrary openLibrary) { + + var books = FunctionCallback.builder() + .description("Get list of Books by title") + .method("getBooks", String.class) + .targetObject(openLibrary) + .build(); + + var bookTitlesByAuthor = FunctionCallback.builder() + .description("Get book titles by author") + .method("getBookTitlesByAuthor", String.class) + .targetObject(openLibrary) + .build(); + + return ToolHelper.toToolRegistration(books, bookTitlesByAuthor); + } + private static ResourceRegistration systemInfoResourceRegistration() { // Create a resource registration for system information @@ -131,8 +171,14 @@ public class McpServerConfig { } private static ToolRegistration weatherToolRegistration(McpAsyncServer server) { - return new ToolRegistration( - new McpSchema.Tool("weather", "Weather forecast tool by location", Map.of("city", "String")), + String emptyJsonSchema = """ + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"city" : "string"} + } + """; + return new ToolRegistration(new McpSchema.Tool("weather", "Weather forecast tool by location", emptyJsonSchema), (arguments) -> { String city = (String) arguments.get("city"); @@ -210,4 +256,17 @@ public class McpServerConfig { }); } + public static String paymentTransactionStatus(String transactionId, String accountName) { + return "The status for " + transactionId + ", by " + accountName + " is PENDING"; + } + + public Function toUpperCase() { + return String::toUpperCase; + } + + @Bean + public OpenLibrary openLibrary() { + return new OpenLibrary(RestClient.builder()); + } + } diff --git a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/OpenLibrary.java b/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/OpenLibrary.java new file mode 100644 index 0000000..7f96573 --- /dev/null +++ b/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/OpenLibrary.java @@ -0,0 +1,74 @@ +/* +* 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.sample.server; + +import java.util.List; +import java.util.Map; + +import org.springframework.web.client.RestClient; + +/** + * @author Christian Tzolov + */ + +public class OpenLibrary { + + private RestClient restClient; + + public OpenLibrary(RestClient.Builder restClientBuilder) { + this.restClient = restClientBuilder.baseUrl("https://openlibrary.org").build(); + } + + public record Books(Integer numFound, Integer start, Boolean numFoundExact, List> docs) { + } + + public record Book(List isbn, String title, List authorName) { + } + + public List getBooks(String title) { + Books books = restClient.get() + .uri(uriBuilder -> uriBuilder.path("/search.json").queryParam("q", title).build()) + .retrieve() + .body(Books.class); + if (books == null) { + return List.of(); + } + return books.docs.stream() + .map(doc -> new Book((List) doc.get("isbn"), (String) doc.get("title"), + (List) doc.get("author_name"))) + .toList(); + } + + public List getBookTitlesByAuthor(String authorName) { + var books = restClient.get() + .uri(uriBuilder -> uriBuilder.path("/search/authors.json").queryParam("q", authorName).build()) + .retrieve() + .body(Books.class); + if (books == null) { + return List.of(); + } + return books.docs.stream().map(doc -> (String) doc.get("top_work")).toList(); + } + + public static void main(String[] args) { + OpenLibrary openLibrary = new OpenLibrary(RestClient.builder()); + List books = openLibrary.getBooks("Spring Framework"); + System.out.println(books); + List booksByAuthor = openLibrary.getBookTitlesByAuthor("Craig Walls"); + System.out.println(booksByAuthor); + } + +} diff --git a/spring-ai-mcp-sample/src/main/resources/application.properties b/spring-ai-mcp-sample/src/main/resources/application.properties index 8682ce9..4a0226c 100644 --- a/spring-ai-mcp-sample/src/main/resources/application.properties +++ b/spring-ai-mcp-sample/src/main/resources/application.properties @@ -1,16 +1,8 @@ -# Server port -# server.port=8080 - -# Logging -# logging.level.org.springframework.ai.mcp=DEBUG -# logging.level.org.springframework.web=INFO - -spring.main.banner-mode=off -# logging.level.root=OFF # spring.main.web-application-type=none -# logging.pattern.console= -logging.level.org.springframework.ai.mcp.*=OFF -logging.level.org.springframework.ai.mcp.spec.*=OFF -logging.level.org.springframework.**=OFF + +# NOTE: You must disable the banner and the console logging +# to allow the STDIO transport to work !!! +spring.main.banner-mode=off +logging.pattern.console= transport.mode=sse \ No newline at end of file diff --git a/spring-ai-mcp/src/main/java/org/springframework/ai/mcp/spring/ToolHelper.java b/spring-ai-mcp/src/main/java/org/springframework/ai/mcp/spring/ToolHelper.java new file mode 100644 index 0000000..943f988 --- /dev/null +++ b/spring-ai-mcp/src/main/java/org/springframework/ai/mcp/spring/ToolHelper.java @@ -0,0 +1,167 @@ +/* +* 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.spring; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.module.jsonSchema.JsonSchema; +import com.fasterxml.jackson.module.jsonSchema.JsonSchemaGenerator; + +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.mcp.server.McpServer; +import org.springframework.ai.mcp.spec.McpSchema; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.util.ClassUtils; + +/** + * Utility class that provides helper methods for working with Model Context Protocol + * (MCP) tools in a Spring AI environment. This class facilitates the integration between + * Spring AI's function callbacks and MCP's tool system. + * + *

+ * The MCP tool system enables servers to expose executable functionality to language + * models, allowing them to interact with external systems, perform computations, and take + * actions in the real world. Each tool is uniquely identified by a name and includes + * metadata describing its schema. + * + *

+ * This helper class provides methods to: + *

    + *
  • Convert Spring AI's {@link FunctionCallback} instances to MCP tool + * registrations
  • + *
  • Generate JSON schemas for tool input validation
  • + *
+ * + * @author Christian Tzolov + * @see org.springframework.ai.model.function.FunctionCallback + * @see org.springframework.ai.mcp.server.McpServer.ToolRegistration + * @see org.springframework.ai.mcp.spec.McpSchema.Tool + */ +public final class ToolHelper { + + private ToolHelper() { + } + + public static List toToolRegistration(List functionCallbacks) { + return functionCallbacks.stream().map(ToolHelper::toToolRegistration).toList(); + } + + public static List toToolRegistration(FunctionCallback... functionCallbacks) { + return toToolRegistration(List.of(functionCallbacks)); + } + + /** + * Converts a Spring AI FunctionCallback to an MCP ToolRegistration. This enables + * Spring AI functions to be exposed as MCP tools that can be discovered and invoked + * by language models. + * + *

+ * The conversion process: + *

    + *
  • Creates an MCP Tool with the function's name and input schema
  • + *
  • Wraps the function's execution in a ToolRegistration that handles the MCP + * protocol
  • + *
  • Provides error handling and result formatting according to MCP + * specifications
  • + *
+ * + * You can use the FunctionCallback builder to create a new instance of + * FunctionCallback using either java.util.function.Function or Method reference. + * @param functionCallback the Spring AI function callback to convert + * @return an MCP ToolRegistration that wraps the function callback + * @throws RuntimeException if there's an error during the function execution + */ + public static McpServer.ToolRegistration toToolRegistration(FunctionCallback functionCallback) { + + var tool = new McpSchema.Tool(functionCallback.getName(), functionCallback.getName(), + functionCallback.getInputTypeSchema()); + + return new McpServer.ToolRegistration(tool, request -> { + try { + String callResult = functionCallback.call(ModelOptionsUtils.toJsonString(request)); + return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(callResult)), false); + } + catch (Exception e) { + return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(e.getMessage())), true); + } + }); + } + + /** + * Generates a JSON schema for a map of named classes using the default ObjectMapper. + * This schema can be used to validate tool inputs according to the MCP specification. + * @param namedClasses a map of class names to their corresponding Class objects + * @return a JSON schema string that describes the structure of the named classes + * @throws RuntimeException if schema generation fails + * @see #generateJsonSchema(Map, ObjectMapper) + */ + public static String generateJsonSchema(Map> namedClasses) { + return generateJsonSchema(namedClasses, new ObjectMapper()); + } + + /** + * Generates a JSON schema for a map of named classes using a custom ObjectMapper. The + * generated schema follows the JSON Schema Draft 2020-12 specification and describes + * the structure of the provided classes. + * + *

+ * This method: + *

    + *
  • Creates a schema that validates the structure of tool inputs
  • + *
  • Excludes ToolContext class from schema generation
  • + *
  • Uses Jackson's JsonSchemaGenerator for accurate type representation
  • + *
+ * @param namedClasses a map of class names to their corresponding Class objects + * @param mapper the ObjectMapper to use for JSON processing + * @return a JSON schema string that describes the structure of the named classes + * @throws RuntimeException if schema generation fails + */ + public static String generateJsonSchema(Map> namedClasses, ObjectMapper mapper) { + try { + JsonSchemaGenerator schemaGen = new JsonSchemaGenerator(mapper); + + ObjectNode rootNode = mapper.createObjectNode(); + rootNode.put("$schema", "https://json-schema.org/draft/2020-12/schema"); + rootNode.put("type", "object"); + ObjectNode propertiesNode = rootNode.putObject("properties"); + + for (Map.Entry> entry : namedClasses.entrySet()) { + String className = entry.getKey(); + Class clazz = entry.getValue(); + + if (ClassUtils.isAssignable(clazz, ToolContext.class)) { + // Skip the ToolContext class from the schema generation. + continue; + } + + JsonSchema schema = schemaGen.generateSchema(clazz); + JsonNode schemaNode = mapper.valueToTree(schema); + propertiesNode.set(className, schemaNode); + } + + return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(rootNode); + } + catch (Exception e) { + throw new RuntimeException(e); + } + } + +}