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:
Christian Tzolov 2025-01-06 21:29:06 +01:00
commit aa26cfa848
7 changed files with 392 additions and 51 deletions

View file

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

View file

@ -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<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
* 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<String, Object> 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<String, Object> 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<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)
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<ModelHint> hints, Double costPriority, Double speedPriority,
Double intelligencePriority) {
}
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public record ModelHint(String name) {
}
// ---------------------------
// Pagination Interfaces
// ---------------------------

View file

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

View file

@ -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<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);
// Simulate server sending tools/list_changed notification

View file

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

View file

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

View file

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