Skip to content

Commit 5d968b8

Browse files
committed
feat(mcp): enhance @mcptool with title and advanced annotations
This commit expands the `@McpTool` annotation to fully support the latest Model Context Protocol (MCP) tool specification, allowing developers to provide richer metadata to the LLM. Details: * Added support for the `title` attribute in `@McpTool` for human-readable display names. * Introduced nested `@McpAnnotations` for tools to support execution hints: `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint`. * Updated the `McpRoute` APT generator to parse the nested tool annotations from their string representation, falling back to spec defaults when omitted. * Fixed compilation errors in the code generator by correctly aligning the constructor arguments for `McpSchema.ToolAnnotations`.
1 parent 6ec7b65 commit 5d968b8

File tree

5 files changed

+165
-17
lines changed

5 files changed

+165
-17
lines changed

modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java

Lines changed: 102 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,23 @@ public List<String> generateMcpDefinitionMethod(boolean kt) {
9191
var methodSummary = method.map(JavaDocNode::getSummary).orElse("");
9292
var methodDescription = method.map(JavaDocNode::getDescription).orElse("");
9393
var methodSummaryAndDescription = method.map(JavaDocNode::getFullDescription).orElse("");
94+
9495
if (isMcpTool()) {
9596
String toolName = extractAnnotationValue("io.jooby.annotation.mcp.McpTool", "name");
9697
if (toolName.isEmpty()) {
9798
toolName = getMethodName();
9899
}
100+
101+
// Extract the new title attribute
102+
String title = extractAnnotationValue("io.jooby.annotation.mcp.McpTool", "title");
103+
var titleArg =
104+
title.isEmpty()
105+
? (methodSummary.isEmpty() ? "null" : string(methodSummary))
106+
: string(title);
107+
99108
String description = extractAnnotationValue("io.jooby.annotation.mcp.McpTool", "description");
100109
if (description.isEmpty()) {
101-
description = methodSummaryAndDescription;
110+
description = methodDescription;
102111
}
103112

104113
if (kt) {
@@ -147,21 +156,19 @@ public List<String> generateMcpDefinitionMethod(boolean kt) {
147156
indent(6), "var req = schema.putArray(", string("required"), ")", semicolon(kt)));
148157
}
149158

159+
// --- PARAMETER SCHEMA GENERATION ---
150160
for (var param : getParameters(true)) {
151161
var type = param.getType().getRawType().toString();
152162
if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange")
153163
|| type.equals("io.modelcontextprotocol.common.McpTransportContext")
154164
|| type.equals("io.jooby.Context")) continue;
155165

156166
var mcpName = param.getMcpName();
157-
158-
// 1. Extract the description from the @McpParam annotation
159167
var paramDescription = param.getMcpDescription();
160168
if (paramDescription == null) {
161169
paramDescription = method.map(it -> it.getParameterDoc(param.getName())).orElse("");
162170
}
163171

164-
// 2. Generate the schema and inject the description directly
165172
if (kt) {
166173
buffer.add(
167174
statement(
@@ -238,6 +245,7 @@ public List<String> generateMcpDefinitionMethod(boolean kt) {
238245
}
239246
}
240247

248+
// --- OUTPUT SCHEMA GENERATION ---
241249
String returnTypeStr = getReturnType().getRawType().toString();
242250
boolean generateOutputSchema = hasOutputSchema();
243251
String outputSchemaArg = "null";
@@ -283,30 +291,83 @@ public List<String> generateMcpDefinitionMethod(boolean kt) {
283291
}
284292
}
285293

294+
// --- NESTED ANNOTATION EXTRACTION ---
295+
String annotationsArg = "null";
296+
var toolAnnotation = parseToolAnnotation();
297+
286298
if (kt) {
299+
if (toolAnnotation != null) {
300+
annotationsArg = "annotations";
301+
buffer.add(
302+
statement(
303+
indent(6),
304+
"val annotations = io.modelcontextprotocol.spec.McpSchema.ToolAnnotations(",
305+
methodSummaryAndDescription.isEmpty()
306+
? "null"
307+
: string(methodSummaryAndDescription),
308+
", ",
309+
toolAnnotation.readOnlyHint(),
310+
", ",
311+
toolAnnotation.destructiveHint(),
312+
", ",
313+
toolAnnotation.idempotentHint(),
314+
", ",
315+
toolAnnotation.openWorldHint(),
316+
", null)"));
317+
}
318+
287319
buffer.add(
288320
statement(
289321
indent(6),
290322
"return io.modelcontextprotocol.spec.McpSchema.Tool(",
291323
string(toolName),
292-
", null, ",
324+
", ",
325+
titleArg,
326+
", ",
293327
string(description),
294328
", mapper.treeToValue(schema,"
295329
+ " io.modelcontextprotocol.spec.McpSchema.JsonSchema::class.java), ",
296330
outputSchemaArg,
297-
", null, null)"));
331+
", ",
332+
annotationsArg,
333+
", null)"));
298334
} else {
335+
if (toolAnnotation != null) {
336+
annotationsArg = "annotations";
337+
buffer.add(
338+
statement(
339+
indent(6),
340+
"var annotations = new io.modelcontextprotocol.spec.McpSchema.ToolAnnotations(",
341+
methodSummaryAndDescription.isEmpty()
342+
? "null"
343+
: string(methodSummaryAndDescription),
344+
", ",
345+
toolAnnotation.readOnlyHint(),
346+
", ",
347+
toolAnnotation.destructiveHint(),
348+
", ",
349+
toolAnnotation.idempotentHint(),
350+
", ",
351+
toolAnnotation.openWorldHint(),
352+
", null)",
353+
semicolon(kt)));
354+
}
355+
299356
buffer.add(
300357
statement(
301358
indent(6),
302359
"return new io.modelcontextprotocol.spec.McpSchema.Tool(",
303360
string(toolName),
304-
", null, ",
361+
", ",
362+
titleArg,
363+
", ",
305364
string(description),
306365
", mapper.treeToValue(schema,"
307366
+ " io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), ",
308367
outputSchemaArg,
309-
", null, null)",
368+
", ",
369+
annotationsArg,
370+
", null)",
310371
semicolon(kt)));
311372
}
312373
buffer.add(statement(indent(4), "}\n"));
@@ -944,5 +1005,38 @@ private McpAnnotation parseResourceAnnotation() {
9441005
return new McpAnnotation(String.join(", ", audienceList), lastMod.toString(), priority);
9451006
}
9461007

1008+
private McpToolAnnotation parseToolAnnotation() {
1009+
String rawAnnotations =
1010+
extractAnnotationValue("io.jooby.annotation.mcp.McpTool", "annotations");
1011+
1012+
if (rawAnnotations.isEmpty()) {
1013+
return null; // APT didn't find explicitly declared annotations
1014+
}
1015+
1016+
// Default values matching the @McpAnnotations interface
1017+
String readOnlyHint = "false";
1018+
String destructiveHint = "true";
1019+
String idempotentHint = "false";
1020+
String openWorldHint = "true";
1021+
1022+
if (rawAnnotations.contains("readOnlyHint=")) {
1023+
readOnlyHint = rawAnnotations.replaceAll(".*readOnlyHint=(true|false).*", "$1");
1024+
}
1025+
if (rawAnnotations.contains("destructiveHint=")) {
1026+
destructiveHint = rawAnnotations.replaceAll(".*destructiveHint=(true|false).*", "$1");
1027+
}
1028+
if (rawAnnotations.contains("idempotentHint=")) {
1029+
idempotentHint = rawAnnotations.replaceAll(".*idempotentHint=(true|false).*", "$1");
1030+
}
1031+
if (rawAnnotations.contains("openWorldHint=")) {
1032+
openWorldHint = rawAnnotations.replaceAll(".*openWorldHint=(true|false).*", "$1");
1033+
}
1034+
1035+
return new McpToolAnnotation(readOnlyHint, destructiveHint, idempotentHint, openWorldHint);
1036+
}
1037+
1038+
private record McpToolAnnotation(
1039+
String readOnlyHint, String destructiveHint, String idempotentHint, String openWorldHint) {}
1040+
9471041
private record McpAnnotation(String audience, String lastModified, String priority) {}
9481042
}

modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public class ExampleServer {
2020
* @param a 1st number
2121
* @return sum of the two numbers
2222
*/
23-
@McpTool(name = "calculator")
23+
@McpTool(name = "calculator", annotations = @McpTool.McpAnnotations(readOnlyHint = true))
2424
public int add(@McpParam(name = "a") int a, @McpParam(description = "2nd number") int b) {
2525
return a + b;
2626
}
@@ -45,12 +45,11 @@ public String reviewCode(
4545
uri = "file:///logs/app.log",
4646
name = "Application Logs",
4747
size = 1024,
48-
annotations = {
49-
@McpResource.McpAnnotations(
50-
audience = McpSchema.Role.USER,
51-
lastModified = "1",
52-
priority = 1.5)
53-
})
48+
annotations =
49+
@McpResource.McpAnnotations(
50+
audience = McpSchema.Role.USER,
51+
lastModified = "1",
52+
priority = 1.5))
5453
public String getLogs() {
5554
return "Log content here...";
5655
}

modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,8 @@ private io.modelcontextprotocol.spec.McpSchema.Tool addToolSpec(tools.jackson.da
115115
schema_b.put("description", "2nd number");
116116
props.set("b", schema_b);
117117
req.add("b");
118-
return new io.modelcontextprotocol.spec.McpSchema.Tool("calculator", null, "Add two numbers.A simple calculator.", mapper.treeToValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), null, null, null);
118+
var annotations = new io.modelcontextprotocol.spec.McpSchema.ToolAnnotations("Add two numbers.A simple calculator.", true, true, false, true, null);
119+
return new io.modelcontextprotocol.spec.McpSchema.Tool("calculator", "Add two numbers.", "A simple calculator.", mapper.treeToValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), null, annotations, null);
119120
}
120121
121122
private io.modelcontextprotocol.spec.McpSchema.CallToolResult add(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.CallToolRequest req) {

modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpResource.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,11 @@
5656
int size() default -1;
5757

5858
/** Optional MCP metadata annotations for this resource. */
59-
McpAnnotations[] annotations() default {};
59+
McpAnnotations annotations() default
60+
@McpAnnotations(
61+
audience = {McpSchema.Role.USER},
62+
lastModified = "",
63+
priority = 0.5);
6064

6165
@Retention(RetentionPolicy.RUNTIME)
6266
@Target(ElementType.ANNOTATION_TYPE)

modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpTool.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,60 @@
2121
*/
2222
String name() default "";
2323

24+
/**
25+
* Intended for UI and end-user contexts — optimized to be human-readable and easily understood,
26+
* even by those unfamiliar with domain-specific terminology. If not provided, the name should be
27+
* used for display (except for Tool, where annotations.title should be given precedence over
28+
* using name, if present).
29+
*/
30+
String title() default "";
31+
2432
/**
2533
* A description of what the tool does. Highly recommended for LLM usage.
2634
*
2735
* @return Tool description.
2836
*/
2937
String description() default "";
38+
39+
/** Additional hints for clients. */
40+
McpAnnotations annotations() default @McpAnnotations;
41+
42+
/**
43+
* Additional properties describing a Tool to clients.
44+
*
45+
* <p>All properties in ToolAnnotations are hints. They are not guaranteed to provide a faithful
46+
* description of tool behavior (including descriptive properties like title).
47+
*
48+
* <p>Clients should never make tool use decisions based on ToolAnnotations received from
49+
* untrusted servers.
50+
*/
51+
@Retention(RetentionPolicy.RUNTIME)
52+
@Target(ElementType.ANNOTATION_TYPE)
53+
@interface McpAnnotations {
54+
/** If true, the tool does not modify its environment. */
55+
boolean readOnlyHint() default false;
56+
57+
/**
58+
* If true, the tool may perform destructive updates to its environment. If false, the tool
59+
* performs only additive updates.
60+
*
61+
* <p>(This property is meaningful only when readOnlyHint == false)
62+
*/
63+
boolean destructiveHint() default true;
64+
65+
/**
66+
* If true, calling the tool repeatedly with the same arguments will have no additional effect
67+
* on the its environment.
68+
*
69+
* <p>(This property is meaningful only when readOnlyHint == false)
70+
*/
71+
boolean idempotentHint() default false;
72+
73+
/**
74+
* If true, this tool may interact with an “open world” of external entities. If false, the
75+
* tool’s domain of interaction is closed. For example, the world of a web search tool is open,
76+
* whereas that of a memory tool is not.
77+
*/
78+
boolean openWorldHint() default true;
79+
}
3080
}

0 commit comments

Comments
 (0)