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<String, Object> 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
This commit is contained in:
parent
8334e2ee96
commit
aa26cfa848
13 changed files with 392 additions and 51 deletions
|
|
@ -275,14 +275,14 @@ public class StdioClientTransport implements ClientMcpTransport {
|
||||||
JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.objectMapper, line);
|
JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.objectMapper, line);
|
||||||
if (!this.inboundSink.tryEmitNext(message).isSuccess()) {
|
if (!this.inboundSink.tryEmitNext(message).isSuccess()) {
|
||||||
if (!isClosing) {
|
if (!isClosing) {
|
||||||
logger.error("Failed to enqueue inbound message");
|
logger.error("Failed to enqueue inbound message: {}", message);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception e) {
|
catch (Exception e) {
|
||||||
if (!isClosing) {
|
if (!isClosing) {
|
||||||
logger.error("Error processing inbound message", e);
|
logger.error("Error processing inbound message for line: " + line, e);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
*
|
*
|
||||||
* @author Christian Tzolov
|
* @author Christian Tzolov
|
||||||
*/
|
*/
|
||||||
public class McpSchema {
|
public final class McpSchema {
|
||||||
|
|
||||||
|
private McpSchema() {
|
||||||
|
}
|
||||||
|
|
||||||
public static final String LATEST_PROTOCOL_VERSION = "2024-11-05";
|
public static final String LATEST_PROTOCOL_VERSION = "2024-11-05";
|
||||||
|
|
||||||
|
|
@ -95,6 +98,8 @@ public class McpSchema {
|
||||||
// Sampling Methods
|
// Sampling Methods
|
||||||
public static final String METHOD_SAMPLING_CREATE_MESSAGE = "sampling/createMessage";
|
public static final String METHOD_SAMPLING_CREATE_MESSAGE = "sampling/createMessage";
|
||||||
|
|
||||||
|
private static ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
// JSON-RPC Error Codes
|
// JSON-RPC Error Codes
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
|
|
@ -659,6 +664,15 @@ public class McpSchema {
|
||||||
@JsonProperty("nextCursor") String nextCursor) {
|
@JsonProperty("nextCursor") String nextCursor) {
|
||||||
}// @formatter:on
|
}// @formatter:on
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
record JsonSchema( // @formatter:off
|
||||||
|
@JsonProperty("type") String type,
|
||||||
|
@JsonProperty("properties") Map<String, Object> properties,
|
||||||
|
@JsonProperty("required") List<String> required,
|
||||||
|
@JsonProperty("additionalProperties") Boolean additionalProperties) {
|
||||||
|
} // @formatter:on
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a tool that the server provides. Tools enable servers to expose
|
* Represents a tool that the server provides. Tools enable servers to expose
|
||||||
* executable functionality to the system. Through these tools, you can interact with
|
* executable functionality to the system. Through these tools, you can interact with
|
||||||
|
|
@ -676,7 +690,7 @@ public class McpSchema {
|
||||||
public record Tool( // @formatter:off
|
public record Tool( // @formatter:off
|
||||||
@JsonProperty("name") String name,
|
@JsonProperty("name") String name,
|
||||||
@JsonProperty("description") String description,
|
@JsonProperty("description") String description,
|
||||||
@JsonProperty("inputSchema") Map<String, Object> inputSchema) {
|
@JsonProperty("inputSchema") JsonSchema inputSchema) {
|
||||||
|
|
||||||
public Tool(String name, String description, String schema) {
|
public Tool(String name, String description, String schema) {
|
||||||
this(name, description, parseSchema(schema));
|
this(name, description, parseSchema(schema));
|
||||||
|
|
@ -684,9 +698,9 @@ public class McpSchema {
|
||||||
|
|
||||||
} // @formatter:on
|
} // @formatter:on
|
||||||
|
|
||||||
public static Map<String, Object> parseSchema(String schema) {
|
public static JsonSchema parseSchema(String schema) {
|
||||||
try {
|
try {
|
||||||
return new ObjectMapper().readValue(schema, MAP_TYPE_REF);
|
return OBJECT_MAPPER.readValue(schema, JsonSchema.class);
|
||||||
}
|
}
|
||||||
catch (IOException e) {
|
catch (IOException e) {
|
||||||
throw new IllegalArgumentException("Invalid schema: " + schema, e);
|
throw new IllegalArgumentException("Invalid schema: " + schema, e);
|
||||||
|
|
@ -724,6 +738,18 @@ public class McpSchema {
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
// Sampling Interfaces
|
// Sampling Interfaces
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
||||||
|
public record ModelPreferences(// @formatter:off
|
||||||
|
@JsonProperty("hints") List<ModelHint> 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)
|
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
||||||
public record SamplingMessage(// @formatter:off
|
public record SamplingMessage(// @formatter:off
|
||||||
@JsonProperty("role") Role role,
|
@JsonProperty("role") Role role,
|
||||||
|
|
@ -803,18 +829,6 @@ public class McpSchema {
|
||||||
}
|
}
|
||||||
}// @formatter:on
|
}// @formatter:on
|
||||||
|
|
||||||
// ---------------------------
|
|
||||||
// Model Preferences
|
|
||||||
// ---------------------------
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
|
||||||
public record ModelPreferences(List<ModelHint> hints, Double costPriority, Double speedPriority,
|
|
||||||
Double intelligencePriority) {
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
|
||||||
public record ModelHint(String name) {
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
// Pagination Interfaces
|
// Pagination Interfaces
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ public class DemoServer {
|
||||||
|
|
||||||
var mcpServer = McpServer.using(transport)
|
var mcpServer = McpServer.using(transport)
|
||||||
.serverInfo("Weather Forecast", "1.0.0")
|
.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) -> {
|
(arguments) -> {
|
||||||
String city = (String) arguments.get("city");
|
String city = (String) arguments.get("city");
|
||||||
return new CallToolResult(List.of(), false);
|
return new CallToolResult(List.of(), false);
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@ import java.util.Map;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import org.springframework.ai.mcp.MockMcpTransport;
|
import org.springframework.ai.mcp.MockMcpTransport;
|
||||||
|
|
@ -39,7 +41,7 @@ import static org.awaitility.Awaitility.await;
|
||||||
class McpAsyncClientResponseHandlerTests {
|
class McpAsyncClientResponseHandlerTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testToolsChangeNotificationHandling() {
|
void testToolsChangeNotificationHandling() throws JsonProcessingException {
|
||||||
MockMcpTransport transport = new MockMcpTransport();
|
MockMcpTransport transport = new MockMcpTransport();
|
||||||
|
|
||||||
// Create a list to store received tools for verification
|
// 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
|
// Create a mock tools list that the server will return
|
||||||
Map<String, Object> inputSchema = Map.of("type", "object", "properties", Map.of(), "required", List.of());
|
Map<String, Object> 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);
|
McpSchema.ListToolsResult mockToolsResult = new McpSchema.ListToolsResult(List.of(mockTool), null);
|
||||||
|
|
||||||
// Simulate server sending tools/list_changed notification
|
// Simulate server sending tools/list_changed notification
|
||||||
|
|
|
||||||
|
|
@ -107,10 +107,17 @@ public abstract class AbstractMcpAsyncServerTests {
|
||||||
// ---------------------------------------
|
// ---------------------------------------
|
||||||
// Tools Tests
|
// Tools Tests
|
||||||
// ---------------------------------------
|
// ---------------------------------------
|
||||||
|
String emptyJsonSchema = """
|
||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testAddTool() {
|
void testAddTool() {
|
||||||
Tool newTool = new McpSchema.Tool("new-tool", "New test tool", Map.of("input", "string"));
|
Tool newTool = new McpSchema.Tool("new-tool", "New test tool", emptyJsonSchema);
|
||||||
var mcpAsyncServer = McpServer.using(createMcpTransport())
|
var mcpAsyncServer = McpServer.using(createMcpTransport())
|
||||||
.serverInfo("test-server", "1.0.0")
|
.serverInfo("test-server", "1.0.0")
|
||||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||||
|
|
@ -125,7 +132,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testAddDuplicateTool() {
|
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())
|
var mcpAsyncServer = McpServer.using(createMcpTransport())
|
||||||
.serverInfo("test-server", "1.0.0")
|
.serverInfo("test-server", "1.0.0")
|
||||||
|
|
@ -146,7 +153,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testRemoveTool() {
|
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())
|
var mcpAsyncServer = McpServer.using(createMcpTransport())
|
||||||
.serverInfo("test-server", "1.0.0")
|
.serverInfo("test-server", "1.0.0")
|
||||||
|
|
@ -175,7 +182,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testNotifyToolsListChanged() {
|
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())
|
var mcpAsyncServer = McpServer.using(createMcpTransport())
|
||||||
.serverInfo("test-server", "1.0.0")
|
.serverInfo("test-server", "1.0.0")
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,14 @@ public abstract class AbstractMcpSyncServerTests {
|
||||||
// Tools Tests
|
// Tools Tests
|
||||||
// ---------------------------------------
|
// ---------------------------------------
|
||||||
|
|
||||||
|
String emptyJsonSchema = """
|
||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testAddTool() {
|
void testAddTool() {
|
||||||
var mcpSyncServer = McpServer.using(createMcpTransport())
|
var mcpSyncServer = McpServer.using(createMcpTransport())
|
||||||
|
|
@ -123,7 +131,7 @@ public abstract class AbstractMcpSyncServerTests {
|
||||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||||
.sync();
|
.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
|
assertThatCode(() -> mcpSyncServer
|
||||||
.addTool(new ToolRegistration(newTool, args -> new CallToolResult(List.of(), false))))
|
.addTool(new ToolRegistration(newTool, args -> new CallToolResult(List.of(), false))))
|
||||||
.doesNotThrowAnyException();
|
.doesNotThrowAnyException();
|
||||||
|
|
@ -133,7 +141,7 @@ public abstract class AbstractMcpSyncServerTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testAddDuplicateTool() {
|
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())
|
var mcpSyncServer = McpServer.using(createMcpTransport())
|
||||||
.serverInfo("test-server", "1.0.0")
|
.serverInfo("test-server", "1.0.0")
|
||||||
|
|
@ -151,7 +159,7 @@ public abstract class AbstractMcpSyncServerTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testRemoveTool() {
|
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())
|
var mcpSyncServer = McpServer.using(createMcpTransport())
|
||||||
.serverInfo("test-server", "1.0.0")
|
.serverInfo("test-server", "1.0.0")
|
||||||
|
|
|
||||||
|
|
@ -322,12 +322,22 @@ public class SseAsyncIntegrationTests {
|
||||||
// ---------------------------------------
|
// ---------------------------------------
|
||||||
// Tools Tests
|
// Tools Tests
|
||||||
// ---------------------------------------
|
// ---------------------------------------
|
||||||
|
|
||||||
|
String emptyJsonSchema = """
|
||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
||||||
void testToolCallSuccess() {
|
void testToolCallSuccess() {
|
||||||
|
|
||||||
var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
|
var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
|
||||||
ToolRegistration tool1 = new ToolRegistration(
|
ToolRegistration tool1 = new ToolRegistration(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema),
|
||||||
new McpSchema.Tool("tool1", "tool1 description", Map.of("city", "String")), request -> {
|
request -> {
|
||||||
// perform a blocking call to a remote service
|
// perform a blocking call to a remote service
|
||||||
String response = RestClient.create()
|
String response = RestClient.create()
|
||||||
.get()
|
.get()
|
||||||
|
|
@ -363,8 +373,8 @@ public class SseAsyncIntegrationTests {
|
||||||
void testToolListChangeHandlingSuccess() {
|
void testToolListChangeHandlingSuccess() {
|
||||||
|
|
||||||
var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
|
var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
|
||||||
ToolRegistration tool1 = new ToolRegistration(
|
ToolRegistration tool1 = new ToolRegistration(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema),
|
||||||
new McpSchema.Tool("tool1", "tool1 description", Map.of("city", "String")), request -> {
|
request -> {
|
||||||
// perform a blocking call to a remote service
|
// perform a blocking call to a remote service
|
||||||
String response = RestClient.create()
|
String response = RestClient.create()
|
||||||
.get()
|
.get()
|
||||||
|
|
@ -413,8 +423,8 @@ public class SseAsyncIntegrationTests {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add a new tool
|
// Add a new tool
|
||||||
ToolRegistration tool2 = new ToolRegistration(
|
ToolRegistration tool2 = new ToolRegistration(new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema),
|
||||||
new McpSchema.Tool("tool2", "tool2 description", Map.of("city", "String")), request -> callResponse);
|
request -> callResponse);
|
||||||
|
|
||||||
mcpServer.addTool(tool2);
|
mcpServer.addTool(tool2);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.springframework.experimental</groupId>
|
||||||
<artifactId>mcp</artifactId>
|
<artifactId>spring-ai-mcp</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,13 @@ public class SampleClient {
|
||||||
.callTool(new CallToolRequest("calculator", Map.of("operation", "multiply", "a", 2.0, "b", 3.0)));
|
.callTool(new CallToolRequest("calculator", Map.of("operation", "multiply", "a", 2.0, "b", 3.0)));
|
||||||
System.out.println("Calculator Response = " + calcResponse);
|
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
|
// List and demonstrate resources
|
||||||
var resourcesList = client.listResources();
|
var resourcesList = client.listResources();
|
||||||
System.out.println("\nAvailable Resources = " + resourcesList);
|
System.out.println("\nAvailable Resources = " + resourcesList);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package org.springframework.ai.mcp.sample.server;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.slf4j.Logger;
|
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.PromptMessage;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.Role;
|
import org.springframework.ai.mcp.spec.McpSchema.Role;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.TextContent;
|
import org.springframework.ai.mcp.spec.McpSchema.TextContent;
|
||||||
|
import org.springframework.ai.mcp.spec.McpSchema.Tool;
|
||||||
import org.springframework.ai.mcp.spec.ServerMcpTransport;
|
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.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
|
|
@ -55,7 +60,7 @@ public class McpServerConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public McpAsyncServer mcpServer(ServerMcpTransport transport) { // @formatter:off
|
public McpAsyncServer mcpServer(ServerMcpTransport transport, OpenLibrary openLibrary) { // @formatter:off
|
||||||
|
|
||||||
// Configure server capabilities with resource support
|
// Configure server capabilities with resource support
|
||||||
var capabilities = McpSchema.ServerCapabilities.builder()
|
var capabilities = McpSchema.ServerCapabilities.builder()
|
||||||
|
|
@ -71,13 +76,48 @@ public class McpServerConfig {
|
||||||
.capabilities(capabilities)
|
.capabilities(capabilities)
|
||||||
.resources(systemInfoResourceRegistration())
|
.resources(systemInfoResourceRegistration())
|
||||||
.prompts(greetingPromptRegistration())
|
.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<String, String>() {
|
||||||
|
@Override
|
||||||
|
public String apply(String s) {
|
||||||
|
return s.toUpperCase();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.inputType(String.class)
|
||||||
|
.build()))
|
||||||
|
.tools(openLibraryToolRegistrations(openLibrary))
|
||||||
.async();
|
.async();
|
||||||
|
|
||||||
server.addTool(weatherToolRegistration(server));
|
server.addTool(weatherToolRegistration(server));
|
||||||
return server; // @formatter:on
|
return server; // @formatter:on
|
||||||
} // @formatter:on
|
} // @formatter:on
|
||||||
|
|
||||||
|
public static List<ToolRegistration> 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() {
|
private static ResourceRegistration systemInfoResourceRegistration() {
|
||||||
|
|
||||||
// Create a resource registration for system information
|
// Create a resource registration for system information
|
||||||
|
|
@ -131,8 +171,14 @@ public class McpServerConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ToolRegistration weatherToolRegistration(McpAsyncServer server) {
|
private static ToolRegistration weatherToolRegistration(McpAsyncServer server) {
|
||||||
return new ToolRegistration(
|
String emptyJsonSchema = """
|
||||||
new McpSchema.Tool("weather", "Weather forecast tool by location", Map.of("city", "String")),
|
{
|
||||||
|
"$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) -> {
|
(arguments) -> {
|
||||||
String city = (String) arguments.get("city");
|
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<String, String> toUpperCase() {
|
||||||
|
return String::toUpperCase;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public OpenLibrary openLibrary() {
|
||||||
|
return new OpenLibrary(RestClient.builder());
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<Map<String, Object>> docs) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Book(List<String> isbn, String title, List<String> authorName) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Book> 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<String>) doc.get("isbn"), (String) doc.get("title"),
|
||||||
|
(List<String>) doc.get("author_name")))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> 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<Book> books = openLibrary.getBooks("Spring Framework");
|
||||||
|
System.out.println(books);
|
||||||
|
List<String> booksByAuthor = openLibrary.getBookTitlesByAuthor("Craig Walls");
|
||||||
|
System.out.println(booksByAuthor);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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
|
# spring.main.web-application-type=none
|
||||||
# logging.pattern.console=
|
|
||||||
logging.level.org.springframework.ai.mcp.*=OFF
|
# NOTE: You must disable the banner and the console logging
|
||||||
logging.level.org.springframework.ai.mcp.spec.*=OFF
|
# to allow the STDIO transport to work !!!
|
||||||
logging.level.org.springframework.**=OFF
|
spring.main.banner-mode=off
|
||||||
|
logging.pattern.console=
|
||||||
|
|
||||||
transport.mode=sse
|
transport.mode=sse
|
||||||
|
|
@ -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.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This helper class provides methods to:
|
||||||
|
* <ul>
|
||||||
|
* <li>Convert Spring AI's {@link FunctionCallback} instances to MCP tool
|
||||||
|
* registrations</li>
|
||||||
|
* <li>Generate JSON schemas for tool input validation</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @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<McpServer.ToolRegistration> toToolRegistration(List<FunctionCallback> functionCallbacks) {
|
||||||
|
return functionCallbacks.stream().map(ToolHelper::toToolRegistration).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<McpServer.ToolRegistration> 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.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The conversion process:
|
||||||
|
* <ul>
|
||||||
|
* <li>Creates an MCP Tool with the function's name and input schema</li>
|
||||||
|
* <li>Wraps the function's execution in a ToolRegistration that handles the MCP
|
||||||
|
* protocol</li>
|
||||||
|
* <li>Provides error handling and result formatting according to MCP
|
||||||
|
* specifications</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* 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<String, Class<?>> 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.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This method:
|
||||||
|
* <ul>
|
||||||
|
* <li>Creates a schema that validates the structure of tool inputs</li>
|
||||||
|
* <li>Excludes ToolContext class from schema generation</li>
|
||||||
|
* <li>Uses Jackson's JsonSchemaGenerator for accurate type representation</li>
|
||||||
|
* </ul>
|
||||||
|
* @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<String, Class<?>> 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<String, Class<?>> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue