Skip to content

Commit ac82b5d

Browse files
authored
feat: Add CallToolRequest support for dynamic schema handling (#18)
- Enable tools to accept CallToolRequest parameters for runtime schema support - Implement injection in all sync/async and stateful/stateless callbacks - Enhance JsonSchemaGenerator for minimal/partial schema generation - Add 82 comprehensive tests across all implementations - Update documentation with examples and usage guidelines Allows tools to process dynamic schemas at runtime while maintaining backward compatibility with existing tool implementations. Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
1 parent ee61a02 commit ac82b5d

13 files changed

+1168
-10
lines changed

README.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,35 @@ public class CalculatorToolProvider {
421421
}).subscribeOn(Schedulers.boundedElastic());
422422
}
423423

424+
// Tool with CallToolRequest parameter for dynamic schema support
425+
@McpTool(name = "dynamic-processor", description = "Process data with dynamic schema")
426+
public CallToolResult processDynamic(CallToolRequest request) {
427+
// Access the full request including dynamic schema
428+
Map<String, Object> args = request.arguments();
429+
430+
// Process based on runtime schema
431+
String result = "Processed " + args.size() + " arguments dynamically";
432+
433+
return CallToolResult.builder()
434+
.addTextContent(result)
435+
.build();
436+
}
437+
438+
// Tool with mixed parameters - typed and CallToolRequest
439+
@McpTool(name = "hybrid-processor", description = "Process with both typed and dynamic parameters")
440+
public String processHybrid(
441+
@McpToolParam(description = "Action to perform", required = true) String action,
442+
CallToolRequest request) {
443+
444+
// Use typed parameter
445+
String actionResult = "Action: " + action;
446+
447+
// Also access additional dynamic arguments
448+
Map<String, Object> additionalArgs = request.arguments();
449+
450+
return actionResult + " with " + (additionalArgs.size() - 1) + " additional parameters";
451+
}
452+
424453
public static class AreaResult {
425454
public double area;
426455
public String unit;
@@ -433,6 +462,54 @@ public class CalculatorToolProvider {
433462
}
434463
```
435464

465+
#### CallToolRequest Support
466+
467+
The library supports special `CallToolRequest` parameters in tool methods, enabling dynamic schema handling at runtime. This is useful when you need to:
468+
469+
- Accept tools with schemas defined at runtime
470+
- Process requests where the input structure isn't known at compile time
471+
- Build flexible tools that adapt to different input schemas
472+
473+
When a tool method includes a `CallToolRequest` parameter:
474+
- The parameter receives the complete tool request including all arguments
475+
- For methods with only `CallToolRequest`, a minimal schema is generated
476+
- For methods with mixed parameters, only non-`CallToolRequest` parameters are included in the schema
477+
- The `CallToolRequest` parameter is automatically injected and doesn't appear in the tool's input schema
478+
479+
Example usage:
480+
481+
```java
482+
// Tool that accepts any schema at runtime
483+
@McpTool(name = "flexible-tool")
484+
public CallToolResult processAnySchema(CallToolRequest request) {
485+
Map<String, Object> args = request.arguments();
486+
// Process based on whatever schema was provided at runtime
487+
return CallToolResult.success(processedResult);
488+
}
489+
490+
// Tool with both typed and dynamic parameters
491+
@McpTool(name = "mixed-tool")
492+
public String processMixed(
493+
@McpToolParam("operation") String operation,
494+
@McpToolParam("count") int count,
495+
CallToolRequest request) {
496+
497+
// Use typed parameters for known fields
498+
String result = operation + " x " + count;
499+
500+
// Access any additional fields from the request
501+
Map<String, Object> allArgs = request.arguments();
502+
503+
return result;
504+
}
505+
```
506+
507+
This feature works with all tool callback types:
508+
- `SyncMcpToolMethodCallback` - Synchronous with server exchange
509+
- `AsyncMcpToolMethodCallback` - Asynchronous with server exchange
510+
- `SyncStatelessMcpToolMethodCallback` - Synchronous stateless
511+
- `AsyncStatelessMcpToolMethodCallback` - Asynchronous stateless
512+
436513
### Async Tool Example
437514

438515
```java
@@ -1168,6 +1245,7 @@ public class McpConfig {
11681245
- **Comprehensive validation** - Ensures method signatures are compatible with MCP operations
11691246
- **URI template support** - Powerful URI template handling for resource and completion operations
11701247
- **Tool support with automatic JSON schema generation** - Create MCP tools with automatic input/output schema generation from method signatures
1248+
- **Dynamic schema support via CallToolRequest** - Tools can accept `CallToolRequest` parameters to handle dynamic schemas at runtime
11711249
- **Logging consumer support** - Handle logging message notifications from MCP servers
11721250
- **Sampling support** - Handle sampling requests from MCP servers
11731251
- **Spring integration** - Seamless integration with Spring Framework and Spring AI, including support for both stateful and stateless operations

mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AbstractAsyncMcpToolMethodCallback.java

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,31 @@ protected Object callMethod(Object[] methodArguments) {
9393
* @return An array of method arguments
9494
*/
9595
protected Object[] buildMethodArguments(T exchangeOrContext, Map<String, Object> toolInputArguments) {
96+
return buildMethodArguments(exchangeOrContext, toolInputArguments, null);
97+
}
98+
99+
/**
100+
* Builds the method arguments from the context, tool input arguments, and optionally
101+
* the full request.
102+
* @param exchangeOrContext The exchange or context object (e.g.,
103+
* McpAsyncServerExchange or McpTransportContext)
104+
* @param toolInputArguments The input arguments from the tool request
105+
* @param request The full CallToolRequest (optional, can be null)
106+
* @return An array of method arguments
107+
*/
108+
protected Object[] buildMethodArguments(T exchangeOrContext, Map<String, Object> toolInputArguments,
109+
CallToolRequest request) {
96110
return Stream.of(this.toolMethod.getParameters()).map(parameter -> {
97-
Object rawArgument = toolInputArguments.get(parameter.getName());
111+
// Check if parameter is CallToolRequest type
112+
if (CallToolRequest.class.isAssignableFrom(parameter.getType())) {
113+
return request;
114+
}
98115

99116
if (isExchangeOrContextType(parameter.getType())) {
100117
return exchangeOrContext;
101118
}
119+
120+
Object rawArgument = toolInputArguments.get(parameter.getName());
102121
return buildTypedArgument(rawArgument, parameter.getParameterizedType());
103122
}).toArray();
104123
}

mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AbstractSyncMcpToolMethodCallback.java

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,31 @@ protected Object callMethod(Object[] methodArguments) {
8989
* @return An array of method arguments
9090
*/
9191
protected Object[] buildMethodArguments(T exchangeOrContext, Map<String, Object> toolInputArguments) {
92+
return buildMethodArguments(exchangeOrContext, toolInputArguments, null);
93+
}
94+
95+
/**
96+
* Builds the method arguments from the context, tool input arguments, and optionally
97+
* the full request.
98+
* @param exchangeOrContext The exchange or context object (e.g.,
99+
* McpSyncServerExchange or McpTransportContext)
100+
* @param toolInputArguments The input arguments from the tool request
101+
* @param request The full CallToolRequest (optional, can be null)
102+
* @return An array of method arguments
103+
*/
104+
protected Object[] buildMethodArguments(T exchangeOrContext, Map<String, Object> toolInputArguments,
105+
CallToolRequest request) {
92106
return Stream.of(this.toolMethod.getParameters()).map(parameter -> {
93-
Object rawArgument = toolInputArguments.get(parameter.getName());
107+
// Check if parameter is CallToolRequest type
108+
if (CallToolRequest.class.isAssignableFrom(parameter.getType())) {
109+
return request;
110+
}
94111

95112
if (isExchangeOrContextType(parameter.getType())) {
96113
return exchangeOrContext;
97114
}
115+
116+
Object rawArgument = toolInputArguments.get(parameter.getName());
98117
return buildTypedArgument(rawArgument, parameter.getParameterizedType());
99118
}).toArray();
100119
}

mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AsyncMcpToolMethodCallback.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,9 @@ public Mono<CallToolResult> apply(McpAsyncServerExchange exchange, CallToolReque
6060

6161
return validateRequest(request).then(Mono.defer(() -> {
6262
try {
63-
// Build arguments for the method call
64-
Object[] args = this.buildMethodArguments(exchange, request.arguments());
63+
// Build arguments for the method call, passing the full request for
64+
// CallToolRequest parameter support
65+
Object[] args = this.buildMethodArguments(exchange, request.arguments(), request);
6566

6667
// Invoke the method
6768
Object result = this.callMethod(args);

mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AsyncStatelessMcpToolMethodCallback.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ public Mono<CallToolResult> apply(McpTransportContext mcpTransportContext, CallT
6262
return validateRequest(request).then(Mono.defer(() -> {
6363
try {
6464
// Build arguments for the method call
65-
Object[] args = this.buildMethodArguments(mcpTransportContext, request.arguments());
65+
Object[] args = this.buildMethodArguments(mcpTransportContext, request.arguments(), request);
6666

6767
// Invoke the method
6868
Object result = this.callMethod(args);

mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/SyncMcpToolMethodCallback.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,9 @@ public CallToolResult apply(McpSyncServerExchange exchange, CallToolRequest requ
5858
validateRequest(request);
5959

6060
try {
61-
// Build arguments for the method call
62-
Object[] args = this.buildMethodArguments(exchange, request.arguments());
61+
// Build arguments for the method call, passing the full request for
62+
// CallToolRequest parameter support
63+
Object[] args = this.buildMethodArguments(exchange, request.arguments(), request);
6364

6465
// Invoke the method
6566
Object result = this.callMethod(args);

mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/SyncStatelessMcpToolMethodCallback.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ public CallToolResult apply(McpTransportContext mcpTransportContext, CallToolReq
5252

5353
try {
5454
// Build arguments for the method call
55-
Object[] args = this.buildMethodArguments(mcpTransportContext, callToolRequest.arguments());
55+
Object[] args = this.buildMethodArguments(mcpTransportContext, callToolRequest.arguments(),
56+
callToolRequest);
5657

5758
// Invoke the method
5859
Object result = this.callMethod(args);

mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/utils/JsonSchemaGenerator.java

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import java.lang.reflect.Parameter;
2121
import java.lang.reflect.Type;
2222
import java.util.ArrayList;
23+
import java.util.Arrays;
2324
import java.util.List;
2425
import java.util.Map;
2526

@@ -41,6 +42,7 @@
4142

4243
import io.modelcontextprotocol.server.McpAsyncServerExchange;
4344
import io.modelcontextprotocol.server.McpSyncServerExchange;
45+
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
4446
import io.modelcontextprotocol.util.Assert;
4547
import io.modelcontextprotocol.util.Utils;
4648
import io.swagger.v3.oas.annotations.media.Schema;
@@ -93,6 +95,30 @@ public static String generateForMethodInput(Method method) {
9395
}
9496

9597
private static String internalGenerateFromMethodArguments(Method method) {
98+
// Check if method has CallToolRequest parameter
99+
boolean hasCallToolRequestParam = Arrays.stream(method.getParameterTypes())
100+
.anyMatch(type -> CallToolRequest.class.isAssignableFrom(type));
101+
102+
// If method has CallToolRequest, return minimal schema
103+
if (hasCallToolRequestParam) {
104+
// Check if there are other parameters besides CallToolRequest and exchange
105+
// types
106+
boolean hasOtherParams = Arrays.stream(method.getParameters()).anyMatch(param -> {
107+
Class<?> type = param.getType();
108+
return !CallToolRequest.class.isAssignableFrom(type)
109+
&& !McpSyncServerExchange.class.isAssignableFrom(type)
110+
&& !McpAsyncServerExchange.class.isAssignableFrom(type);
111+
});
112+
113+
// If only CallToolRequest (and possibly exchange), return empty schema
114+
if (!hasOtherParams) {
115+
ObjectNode schema = JsonParser.getObjectMapper().createObjectNode();
116+
schema.put("type", "object");
117+
schema.putObject("properties");
118+
schema.putArray("required");
119+
return schema.toPrettyString();
120+
}
121+
}
96122

97123
ObjectNode schema = JsonParser.getObjectMapper().createObjectNode();
98124
schema.put("$schema", SchemaVersion.DRAFT_2020_12.getIdentifier());
@@ -104,11 +130,15 @@ private static String internalGenerateFromMethodArguments(Method method) {
104130
for (int i = 0; i < method.getParameterCount(); i++) {
105131
String parameterName = method.getParameters()[i].getName();
106132
Type parameterType = method.getGenericParameterTypes()[i];
133+
134+
// Skip special parameter types
107135
if (parameterType instanceof Class<?> parameterClass
108136
&& (ClassUtils.isAssignable(McpSyncServerExchange.class, parameterClass)
109-
|| ClassUtils.isAssignable(McpAsyncServerExchange.class, parameterClass))) {
137+
|| ClassUtils.isAssignable(McpAsyncServerExchange.class, parameterClass)
138+
|| ClassUtils.isAssignable(CallToolRequest.class, parameterClass))) {
110139
continue;
111140
}
141+
112142
if (isMethodParameterRequired(method, i)) {
113143
required.add(parameterName);
114144
}
@@ -143,6 +173,15 @@ private static String internalGenerateFromClass(Class<?> clazz) {
143173
return jsonSchema.toPrettyString();
144174
}
145175

176+
/**
177+
* Check if a method has a CallToolRequest parameter.
178+
* @param method The method to check
179+
* @return true if the method has a CallToolRequest parameter, false otherwise
180+
*/
181+
public static boolean hasCallToolRequestParameter(Method method) {
182+
return Arrays.stream(method.getParameterTypes()).anyMatch(type -> CallToolRequest.class.isAssignableFrom(type));
183+
}
184+
146185
private static boolean isMethodParameterRequired(Method method, int index) {
147186
Parameter parameter = method.getParameters()[index];
148187

mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/SyncMcpToolProvider.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.springaicommunity.mcp.provider;
1818

1919
import java.lang.reflect.Method;
20+
import java.util.Arrays;
2021
import java.util.List;
2122
import java.util.function.BiFunction;
2223
import java.util.stream.Stream;
@@ -78,7 +79,21 @@ public List<SyncToolSpecification> getToolSpecifications() {
7879

7980
String toolDescription = toolAnnotation.description();
8081

81-
String inputSchema = JsonSchemaGenerator.generateForMethodInput(mcpToolMethod);
82+
// Check if method has CallToolRequest parameter
83+
boolean hasCallToolRequestParam = Arrays.stream(mcpToolMethod.getParameterTypes())
84+
.anyMatch(type -> CallToolRequest.class.isAssignableFrom(type));
85+
86+
String inputSchema;
87+
if (hasCallToolRequestParam) {
88+
// For methods with CallToolRequest, generate minimal schema or
89+
// use the one from the request
90+
// The schema generation will handle this appropriately
91+
inputSchema = JsonSchemaGenerator.generateForMethodInput(mcpToolMethod);
92+
logger.debug("Tool method '{}' uses CallToolRequest parameter, using minimal schema", toolName);
93+
}
94+
else {
95+
inputSchema = JsonSchemaGenerator.generateForMethodInput(mcpToolMethod);
96+
}
8297

8398
var toolBuilder = McpSchema.Tool.builder()
8499
.name(toolName)

0 commit comments

Comments
 (0)