Skip to content

Commit c9bcace

Browse files
committed
feat(#9): Support MCP tool outputSchema and structuredContent
1 parent c4e88e7 commit c9bcace

File tree

8 files changed

+222
-21
lines changed

8 files changed

+222
-21
lines changed

src/main/java/com/github/codeboyzhou/mcp/declarative/annotation/McpJsonSchemaDefinition.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
* supported by the MCP protocol. This allows you to use more complex data models in your MCP
1414
* server.
1515
*
16-
* @see McpJsonSchemaDefinitionProperty
16+
* @see McpJsonSchemaProperty
1717
* @author codeboyzhou
1818
*/
1919
@Target(ElementType.TYPE)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
*/
2020
@Target(ElementType.FIELD)
2121
@Retention(RetentionPolicy.RUNTIME)
22-
public @interface McpJsonSchemaDefinitionProperty {
22+
public @interface McpJsonSchemaProperty {
2323
/**
2424
* The name of the JSON schema property. If not specified, the field name will be used.
2525
*

src/main/java/com/github/codeboyzhou/mcp/declarative/reflect/MethodCache.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ public final class MethodCache {
2828
/** The parameters of the cached method. */
2929
private final Parameter[] parameters;
3030

31+
/** The return type of the cached method. */
32+
private final Class<?> returnType;
33+
3134
/** The signature of the cached method. */
3235
private final String methodSignature;
3336

@@ -50,6 +53,7 @@ public MethodCache(Method method) {
5053
this.methodName = method.getName();
5154
this.declaringClass = method.getDeclaringClass();
5255
this.parameters = method.getParameters();
56+
this.returnType = method.getReturnType();
5357
this.methodSignature = method.toGenericString();
5458
this.mcpResourceAnnotation = method.getAnnotation(McpResource.class);
5559
this.mcpPromptAnnotation = method.getAnnotation(McpPrompt.class);
@@ -102,6 +106,15 @@ public Parameter[] getParameters() {
102106
return parameters.clone();
103107
}
104108

109+
/**
110+
* Returns the return type of the cached method.
111+
*
112+
* @return the return type of the cached method
113+
*/
114+
public Class<?> getReturnType() {
115+
return returnType;
116+
}
117+
105118
/**
106119
* Returns the signature of the cached method.
107120
*
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package com.github.codeboyzhou.mcp.declarative.server;
2+
3+
import com.github.codeboyzhou.mcp.declarative.server.component.McpServerTool;
4+
import io.modelcontextprotocol.spec.McpSchema;
5+
6+
/**
7+
* Marker interface for MCP tool method results that return structured content.
8+
*
9+
* <p>This interface is used to distinguish between simple text results and complex results
10+
* containing structured data. When an MCP tool method returns an object implementing this
11+
* interface, the MCP server will provide both text representation and structured data
12+
* representation.
13+
*
14+
* <h2>Usage Scenarios</h2>
15+
*
16+
* <ul>
17+
* <li><strong>Simple Text Results</strong>: When tool methods return String or other primitive
18+
* types, only text content is provided
19+
* <li><strong>Structured Results</strong>: When tool methods return objects implementing this
20+
* interface, both text and structured data are provided
21+
* </ul>
22+
*
23+
* <h2>Example Usage</h2>
24+
*
25+
* <pre>{@code
26+
* public class WeatherTool {
27+
*
28+
* @McpTool(description = "Get weather information")
29+
* public WeatherData getWeather(@McpToolParam(name = "city") String city) {
30+
* final int temperature = 25;
31+
* final String condition = "Sunny";
32+
* return new WeatherData(city, temperature, condition);
33+
* }
34+
*
35+
* // Structured result class implementing McpStructuredContent
36+
* public record WeatherData(
37+
* @McpJsonSchemaProperty(description = "City name") String city,
38+
* @McpJsonSchemaProperty(description = "Temperature") int temperature,
39+
* @McpJsonSchemaProperty(description = "Condition") String condition)
40+
* implements McpStructuredContent {
41+
*
42+
* @Override
43+
* public String asTextContent() {
44+
* return String.format("City: %s, Temperature: %d°C, Condition: %s",
45+
* city, temperature, condition);
46+
* }
47+
* }
48+
* }
49+
* }</pre>
50+
*
51+
* <h2>MCP Response Format</h2>
52+
*
53+
* When a tool returns an object implementing this interface, the MCP response will contain:
54+
*
55+
* <pre>{@code
56+
* {
57+
* "content": [
58+
* {
59+
* "type": "text",
60+
* "text": "City: New York, Temperature: 25°C, Condition: Sunny"
61+
* }
62+
* ],
63+
* "structuredContent": {
64+
* "city": "New York",
65+
* "temperature": 25,
66+
* "condition": "Sunny"
67+
* }
68+
* }
69+
* }</pre>
70+
*
71+
* <h2>Implementation Requirements</h2>
72+
*
73+
* <ul>
74+
* <li>Implementing classes must provide meaningful text representation for direct AI model
75+
* consumption
76+
* <li>Fields of implementing classes will be automatically serialized as structured content
77+
* <li>Text content should be concise and clear for AI understanding
78+
* <li>Structured content should contain complete detailed information
79+
* </ul>
80+
*
81+
* <h2>Default Implementation</h2>
82+
*
83+
* The interface provides a default implementation of {@link #asTextContent()} that returns the
84+
* result of {@link Object#toString()}. If the default implementation doesn't meet requirements,
85+
* implementing classes should override this method.
86+
*
87+
* @see McpServerTool MCP tool component that uses this interface
88+
* @see McpSchema.CallToolResult MCP tool call result specification
89+
* @author codeboyzhou
90+
*/
91+
public interface McpStructuredContent {
92+
/**
93+
* Returns the text representation of this structured content.
94+
*
95+
* <p>The text representation should provide a concise summary of the content, suitable for direct
96+
* consumption by AI models. The result of this method will be used as the text content in the MCP
97+
* response's {@code content} field.
98+
*
99+
* <p>Implementation Guidelines
100+
*
101+
* <ul>
102+
* <li><strong>Conciseness</strong>: Text should be brief, avoiding excessive details
103+
* <li><strong>Readability</strong>: Use natural language that's easy for AI to understand
104+
* <li><strong>Completeness</strong>: Include key information, but not necessarily all details
105+
* <li><strong>Consistency</strong>: Text content should be consistent with structured data
106+
* </ul>
107+
*
108+
* <p>Examples For weather query results:
109+
*
110+
* <ul>
111+
* <li><strong>Good implementation</strong>: "City: New York, Temperature: 25°C, Condition:
112+
* Sunny"
113+
* <li><strong>Poor implementation</strong>: Returning full JSON strings or overly simplified
114+
* information
115+
* </ul>
116+
*
117+
* @return the text representation of the structured content, should not return {@code null}
118+
* @see Object#toString() default implementation uses this method
119+
*/
120+
default String asTextContent() {
121+
return toString();
122+
}
123+
}

src/main/java/com/github/codeboyzhou/mcp/declarative/server/component/McpServerTool.java

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
package com.github.codeboyzhou.mcp.declarative.server.component;
22

33
import com.github.codeboyzhou.mcp.declarative.annotation.McpJsonSchemaDefinition;
4-
import com.github.codeboyzhou.mcp.declarative.annotation.McpJsonSchemaDefinitionProperty;
4+
import com.github.codeboyzhou.mcp.declarative.annotation.McpJsonSchemaProperty;
55
import com.github.codeboyzhou.mcp.declarative.annotation.McpTool;
66
import com.github.codeboyzhou.mcp.declarative.annotation.McpToolParam;
77
import com.github.codeboyzhou.mcp.declarative.enums.JavaTypeToJsonSchemaMapper;
88
import com.github.codeboyzhou.mcp.declarative.reflect.InvocationResult;
99
import com.github.codeboyzhou.mcp.declarative.reflect.MethodCache;
10+
import com.github.codeboyzhou.mcp.declarative.server.McpStructuredContent;
1011
import com.github.codeboyzhou.mcp.declarative.server.converter.McpToolParameterConverter;
1112
import com.github.codeboyzhou.mcp.declarative.util.JacksonHelper;
1213
import com.github.codeboyzhou.mcp.declarative.util.ReflectionHelper;
@@ -55,13 +56,15 @@ public McpServerFeatures.SyncToolSpecification create(Method method) {
5556
final String title = resolveComponentAttributeValue(toolMethod.title());
5657
final String description = resolveComponentAttributeValue(toolMethod.description());
5758

58-
McpSchema.JsonSchema paramSchema = createJsonSchema(methodCache.getParameters());
59+
McpSchema.JsonSchema inputSchema = createJsonSchema(methodCache.getParameters());
60+
Map<String, Object> outputSchema = createJsonSchemaDefinition(methodCache.getReturnType());
5961
McpSchema.Tool tool =
6062
McpSchema.Tool.builder()
6163
.name(name)
6264
.title(title)
6365
.description(description)
64-
.inputSchema(paramSchema)
66+
.inputSchema(inputSchema)
67+
.outputSchema(outputSchema)
6568
.build();
6669

6770
log.debug("Registering tool: {}", JacksonHelper.toJsonString(tool));
@@ -87,9 +90,20 @@ private McpSchema.CallToolResult invoke(
8790
List<Object> params = parameterConverter.convertAll(methodCache.getParameters(), arguments);
8891
InvocationResult invocation = ReflectionHelper.INSTANCE.invoke(instance, methodCache, params);
8992

90-
final boolean isError = invocation.isError();
91-
McpSchema.Content content = new McpSchema.TextContent(invocation.result().toString());
92-
return McpSchema.CallToolResult.builder().content(List.of(content)).isError(isError).build();
93+
Object result = invocation.result();
94+
String textContent = result.toString();
95+
Object structuredContent = Map.of();
96+
97+
if (result instanceof McpStructuredContent mcpStructuredContent) {
98+
textContent = mcpStructuredContent.asTextContent();
99+
structuredContent = mcpStructuredContent;
100+
}
101+
102+
return McpSchema.CallToolResult.builder()
103+
.content(List.of(new McpSchema.TextContent(textContent)))
104+
.structuredContent(structuredContent)
105+
.isError(invocation.isError())
106+
.build();
93107
}
94108

95109
/**
@@ -151,14 +165,12 @@ private Map<String, Object> createJsonSchemaDefinition(Class<?> definitionClass)
151165
List<String> required = new ArrayList<>();
152166

153167
Reflections reflections = injector.getInstance(Reflections.class);
154-
Set<Field> definitionFields =
155-
reflections.getFieldsAnnotatedWith(McpJsonSchemaDefinitionProperty.class);
168+
Set<Field> definitionFields = reflections.getFieldsAnnotatedWith(McpJsonSchemaProperty.class);
156169
List<Field> fields =
157170
definitionFields.stream().filter(f -> f.getDeclaringClass() == definitionClass).toList();
158171

159172
for (Field field : fields) {
160-
McpJsonSchemaDefinitionProperty property =
161-
field.getAnnotation(McpJsonSchemaDefinitionProperty.class);
173+
McpJsonSchemaProperty property = field.getAnnotation(McpJsonSchemaProperty.class);
162174
if (property == null) {
163175
continue;
164176
}

src/main/java/com/github/codeboyzhou/mcp/declarative/util/ReflectionHelper.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,19 +56,19 @@ public InvocationResult invoke(Object instance, MethodCache methodCache, List<Ob
5656
InvocationResult.Builder builder = InvocationResult.builder();
5757
try {
5858
Object result = method.invoke(instance, params.toArray());
59-
if (method.getReturnType() == void.class) {
60-
builder.result("The method call succeeded but has a void return type");
61-
} else {
62-
final String resultIfNull = "The method call succeeded but the return value is null";
63-
builder.result(Objects.requireNonNullElse(result, resultIfNull));
59+
60+
Class<?> returnType = method.getReturnType();
61+
if (returnType == void.class || returnType == Void.class) {
62+
return builder.result("The method call succeeded but has a void return type").build();
6463
}
64+
65+
final String resultIfNull = "The method call succeeded but the return value is null";
66+
return builder.result(Objects.requireNonNullElse(result, resultIfNull)).build();
6567
} catch (Exception e) {
6668
final String errorMessage = "Error invoking method: " + methodCache.getMethodSignature();
6769
log.error(errorMessage, e);
68-
builder.result(errorMessage);
69-
builder.exception(e);
70+
return builder.result(errorMessage).exception(e).build();
7071
}
71-
return builder.build();
7272
}
7373

7474
/**

src/test/java/com/github/codeboyzhou/mcp/declarative/McpServersTest.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
import com.github.codeboyzhou.mcp.declarative.exception.McpServerConfigurationException;
1515
import com.github.codeboyzhou.mcp.declarative.server.McpSseServerInfo;
1616
import com.github.codeboyzhou.mcp.declarative.server.McpStreamableServerInfo;
17+
import com.github.codeboyzhou.mcp.declarative.server.McpStructuredContent;
18+
import com.github.codeboyzhou.mcp.declarative.test.TestMcpToolsStructuredContent;
1719
import com.github.codeboyzhou.mcp.declarative.test.TestSimpleMcpStdioServer;
1820
import com.github.codeboyzhou.mcp.declarative.util.StringHelper;
1921
import io.modelcontextprotocol.client.McpClient;
@@ -309,7 +311,7 @@ private void verifyPromptCalled(
309311

310312
private void verifyToolsRegistered(McpSyncClient client) {
311313
List<McpSchema.Tool> tools = client.listTools().tools();
312-
assertEquals(21, tools.size());
314+
assertEquals(22, tools.size());
313315

314316
verifyToolRegistered(tools, "toolWithDefaultName", "title", "description", Map.of());
315317
verifyToolRegistered(tools, "toolWithDefaultTitle", "Not specified", "description", Map.of());
@@ -399,6 +401,8 @@ private void verifyToolsRegistered(McpSyncClient client) {
399401
"Not specified",
400402
"Not specified",
401403
Map.of("param", Boolean.class));
404+
verifyToolRegistered(
405+
tools, "toolWithReturnStructuredContent", "Not specified", "Not specified", Map.of());
402406
}
403407

404408
@SuppressWarnings("unchecked")
@@ -520,6 +524,12 @@ private void verifyToolsCalled(McpSyncClient client) {
520524
"toolWithBooleanClassParam",
521525
Map.of("param", true),
522526
"toolWithBooleanClassParam is called with param: true");
527+
verifyToolCalled(
528+
client,
529+
"toolWithReturnStructuredContent",
530+
Map.of(),
531+
new TestMcpToolsStructuredContent.TestStructuredContent(1, 2, 3L, 4L, 5.0F, 6.0F, 7.0, 8.0)
532+
.asTextContent());
523533
}
524534

525535
private void verifyToolCalled(
@@ -530,5 +540,9 @@ private void verifyToolCalled(
530540
McpSchema.TextContent content = (McpSchema.TextContent) result.content().get(0);
531541
assertFalse(result.isError());
532542
assertEquals(expectedResult, content.text());
543+
544+
if (result.structuredContent() instanceof McpStructuredContent structuredContent) {
545+
assertEquals(expectedResult, structuredContent.asTextContent());
546+
}
533547
}
534548
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.github.codeboyzhou.mcp.declarative.test;
2+
3+
import com.github.codeboyzhou.mcp.declarative.annotation.McpJsonSchemaProperty;
4+
import com.github.codeboyzhou.mcp.declarative.annotation.McpTool;
5+
import com.github.codeboyzhou.mcp.declarative.server.McpStructuredContent;
6+
7+
public class TestMcpToolsStructuredContent {
8+
9+
public record TestStructuredContent(
10+
@McpJsonSchemaProperty(description = "test int", required = true) int testInt,
11+
@McpJsonSchemaProperty(description = "test integer") Integer testInteger,
12+
@McpJsonSchemaProperty(description = "test long", required = true) long testLong,
13+
@McpJsonSchemaProperty(description = "test long class") Long testLongClass,
14+
@McpJsonSchemaProperty(description = "test float", required = true) float testFloat,
15+
@McpJsonSchemaProperty(description = "test float class") Float testFloatClass,
16+
@McpJsonSchemaProperty(description = "test double") double testDouble,
17+
@McpJsonSchemaProperty(description = "test double class") Double testDoubleClass)
18+
implements McpStructuredContent {
19+
20+
@Override
21+
public String asTextContent() {
22+
return String.format(
23+
"testInt: %d, testInteger: %d, testLong: %d, testLongClass: %d, testFloat: %f, testFloatClass: %f, testDouble: %f, testDoubleClass: %f",
24+
testInt,
25+
testInteger,
26+
testLong,
27+
testLongClass,
28+
testFloat,
29+
testFloatClass,
30+
testDouble,
31+
testDoubleClass);
32+
}
33+
}
34+
35+
@McpTool
36+
public TestStructuredContent toolWithReturnStructuredContent() {
37+
return new TestStructuredContent(1, 2, 3L, 4L, 5.0F, 6.0F, 7.0, 8.0);
38+
}
39+
}

0 commit comments

Comments
 (0)