Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,38 @@ public class CalculatorToolProvider {
}
```

#### Output Schema Generation

The `@McpTool` annotation includes a `generateOutputSchema` attribute that controls whether output schemas are automatically generated for tool methods:

```java
@McpTool(name = "calculate",
description = "Perform calculation",
generateOutputSchema = true) // Explicitly enable output schema generation
public CalculationResult calculate(double value) {
return new CalculationResult(value * 2, "doubled");
}

@McpTool(name = "simple-tool",
description = "Simple tool without output schema") // Default: no output schema
public String simpleTool(String input) {
return "Processed: " + input;
}
```

**Output Schema Behavior:**
- **Default**: `generateOutputSchema = false` - No output schema is automatically generated
- **When enabled**: `generateOutputSchema = true` - Output schema is generated for complex return types
- **Primitive types**: No output schema is generated regardless of the setting (String, int, boolean, etc.)
- **Void types**: No output schema is generated
- **Complex types**: Output schema is generated only when explicitly enabled

**Output Serialization:**
- **String return types**: Returned directly as text content without JSON serialization
- **Complex objects**: Serialized to JSON for text content
- **Null values**: Returned as "null" text content
- **Void methods**: Return "Done" as text content

#### Tool Title Attribute

The `@McpTool` annotation supports a `title` attribute that provides a human-readable display name for tools. This is intended for UI and end-user contexts, optimized to be easily understood even by those unfamiliar with domain-specific terminology.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@

/**
* If true, the tool will generate an output schema for non-primitive output types. If
* false, the tool will not generate an output schema.
* false, the tool will not automatically generate an output schema.
*/
boolean generateOutputSchema() default true;
boolean generateOutputSchema() default false;

/**
* Intended for UI and end-user contexts — optimized to be human-readable and easily
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,21 +238,36 @@ protected Mono<CallToolResult> convertToCallToolResult(Object result) {
* @return A CallToolResult representing the mapped value
*/
protected CallToolResult mapValueToCallToolResult(Object value) {
// Return the result if it's already a CallToolResult
if (value instanceof CallToolResult) {
return (CallToolResult) value;
}

if (returnMode == ReturnMode.VOID) {
Type returnType = this.toolMethod.getGenericReturnType();

if (returnMode == ReturnMode.VOID || returnType == Void.TYPE || returnType == void.class) {
return CallToolResult.builder().addTextContent(JsonParser.toJson("Done")).build();
}
else if (this.returnMode == ReturnMode.STRUCTURED) {

if (this.returnMode == ReturnMode.STRUCTURED) {
String jsonOutput = JsonParser.toJson(value);
Map<String, Object> structuredOutput = JsonParser.fromJson(jsonOutput, MAP_TYPE_REFERENCE);
return CallToolResult.builder().structuredContent(structuredOutput).build();
}

// Default to text output
return CallToolResult.builder().addTextContent(value != null ? value.toString() : "null").build();
if (value == null) {
return CallToolResult.builder().addTextContent("null").build();
}

// For string results in TEXT mode, return the string directly without JSON
// serialization
if (value instanceof String) {
return CallToolResult.builder().addTextContent((String) value).build();
}

// For other types, serialize to JSON
return CallToolResult.builder().addTextContent(JsonParser.toJson(value)).build();
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,14 @@
import java.util.Map;
import java.util.stream.Stream;

import com.fasterxml.jackson.core.type.TypeReference;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import org.springaicommunity.mcp.annotation.McpMeta;
import org.springaicommunity.mcp.annotation.McpProgressToken;
import org.springaicommunity.mcp.annotation.McpTool;
import org.springaicommunity.mcp.method.tool.utils.JsonParser;

import com.fasterxml.jackson.core.type.TypeReference;

import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;

/**
* Abstract base class for creating Function callbacks around tool methods.
*
Expand Down Expand Up @@ -156,17 +154,31 @@ protected CallToolResult processResult(Object result) {
return (CallToolResult) result;
}

if (returnMode == ReturnMode.VOID) {
Type returnType = this.toolMethod.getGenericReturnType();

if (returnMode == ReturnMode.VOID || returnType == Void.TYPE || returnType == void.class) {
return CallToolResult.builder().addTextContent(JsonParser.toJson("Done")).build();
}
else if (this.returnMode == ReturnMode.STRUCTURED) {

if (this.returnMode == ReturnMode.STRUCTURED) {
String jsonOutput = JsonParser.toJson(result);
Map<String, Object> structuredOutput = JsonParser.fromJson(jsonOutput, MAP_TYPE_REFERENCE);
return CallToolResult.builder().structuredContent(structuredOutput).build();
}

// Default to text output
return CallToolResult.builder().addTextContent(result != null ? result.toString() : "null").build();
if (result == null) {
return CallToolResult.builder().addTextContent("null").build();
}

// For string results in TEXT mode, return the string directly without JSON
// serialization
if (result instanceof String) {
return CallToolResult.builder().addTextContent((String) result).build();
}

// For other types, serialize to JSON
return CallToolResult.builder().addTextContent(JsonParser.toJson(result)).build();
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ public class JsonSchemaGenerator {

private static final Map<Class<?>, String> classSchemaCache = new ConcurrentReferenceHashMap<>(256);

private static final Map<Type, String> typeSchemaCache = new ConcurrentReferenceHashMap<>(256);

/*
* Initialize JSON Schema generators.
*/
Expand Down Expand Up @@ -188,6 +190,23 @@ private static String internalGenerateFromClass(Class<?> clazz) {
return jsonSchema.toPrettyString();
}

public static String generateFromType(Type type) {
Assert.notNull(type, "type cannot be null");
return typeSchemaCache.computeIfAbsent(type, JsonSchemaGenerator::internalGenerateFromType);
}

private static String internalGenerateFromType(Type type) {
SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12,
OptionPreset.PLAIN_JSON);
SchemaGeneratorConfig config = configBuilder.with(Option.EXTRA_OPEN_API_FORMAT_VALUES)
.without(Option.FLATTENED_ENUMS_FROM_TOSTRING)
.build();

SchemaGenerator generator = new SchemaGenerator(config);
JsonNode jsonSchema = generator.generateSchema(type);
return jsonSchema.toPrettyString();
}

/**
* Check if a method has a CallToolRequest parameter.
* @param method The method to check
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@ public List<AsyncToolSpecification> getToolSpecifications() {
// Output schema is not generated for primitive types, void,
// CallToolResult, simple value types (String, etc.)
// or if generateOutputSchema attribute is set to false.

if (toolJavaAnnotation.generateOutputSchema()
&& !ReactiveUtils.isReactiveReturnTypeOfVoid(mcpToolMethod)
&& !ReactiveUtils.isReactiveReturnTypeOfCallToolResult(mcpToolMethod)) {
Expand All @@ -124,8 +123,7 @@ public List<AsyncToolSpecification> getToolSpecifications() {
: null;
if (!ClassUtils.isPrimitiveOrWrapper(methodReturnType)
&& !ClassUtils.isSimpleValueType(methodReturnType)) {
toolBuilder
.outputSchema(JsonSchemaGenerator.generateFromClass((Class<?>) typeArgument));
toolBuilder.outputSchema(JsonSchemaGenerator.generateFromType(typeArgument));
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ public List<SyncToolSpecification> getToolSpecifications() {
&& methodReturnType != void.class && !ClassUtils.isPrimitiveOrWrapper(methodReturnType)
&& !ClassUtils.isSimpleValueType(methodReturnType)) {

toolBuilder.outputSchema(JsonSchemaGenerator.generateFromClass(methodReturnType));
toolBuilder
.outputSchema(JsonSchemaGenerator.generateFromType(mcpToolMethod.getGenericReturnType()));
}

var tool = toolBuilder.build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ public List<SyncToolSpecification> getToolSpecifications() {
&& methodReturnType != void.class && !ClassUtils.isPrimitiveOrWrapper(methodReturnType)
&& !ClassUtils.isSimpleValueType(methodReturnType)) {

toolBuilder.outputSchema(JsonSchemaGenerator.generateFromClass(methodReturnType));
toolBuilder
.outputSchema(JsonSchemaGenerator.generateFromType(mcpToolMethod.getGenericReturnType()));
}

var tool = toolBuilder.build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import net.javacrumbs.jsonunit.core.Option;
import org.junit.jupiter.api.Test;
import org.springaicommunity.mcp.annotation.McpTool;
import org.springaicommunity.mcp.annotation.McpToolParam;
Expand All @@ -20,6 +21,9 @@
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;

import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;

/**
* Tests for {@link SyncMcpToolMethodCallback}.
*
Expand Down Expand Up @@ -106,6 +110,11 @@ public TestObject returnObjectTool(String name, int value) {
return new TestObject(name, value);
}

@McpTool(name = "return-list-object-tool", description = "Tool that returns a list of complex objects")
public List<TestObject> returnListObjectTool(String name, int value) {
return List.of(new TestObject(name, value));
}

}

public static class TestObject {
Expand Down Expand Up @@ -507,4 +516,28 @@ public void testToolReturningComplexObject() throws Exception {
assertThat(result.structuredContent()).containsEntry("value", 42);
}

@Test
public void testToolReturningComplexListObject() throws Exception {
TestToolProvider provider = new TestToolProvider();
Method method = TestToolProvider.class.getMethod("returnListObjectTool", String.class, int.class);
SyncMcpToolMethodCallback callback = new SyncMcpToolMethodCallback(ReturnMode.TEXT, method, provider);

McpSyncServerExchange exchange = mock(McpSyncServerExchange.class);
CallToolRequest request = new CallToolRequest("return-list-object-tool", Map.of("name", "test", "value", 42));

CallToolResult result = callback.apply(exchange, request);

assertThat(result).isNotNull();
assertThat(result.isError()).isFalse();
// For complex return types in TEXT mode, the result should be JSON serialized as
// text content
assertThat(result.content()).hasSize(1);
assertThat(result.content().get(0)).isInstanceOf(TextContent.class);

String jsonText = ((TextContent) result.content().get(0)).text();
assertThatJson(jsonText).when(Option.IGNORING_ARRAY_ORDER).isArray().hasSize(1);
assertThatJson(jsonText).when(Option.IGNORING_ARRAY_ORDER).isEqualTo(json("""
[{"name":"test","value":42}]"""));
}

}
Loading