Skip to content
Merged
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
131 changes: 131 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ The Spring integration module provides seamless integration with Spring AI and S

#### Special Parameter Annotations
- **`@McpProgressToken`** - Marks a method parameter to receive the progress token from the request. This parameter is automatically injected and excluded from the generated JSON schema
- **`McpMeta`** - Special parameter type that provides access to metadata from MCP requests, notifications, and results. This parameter is automatically injected and excluded from parameter count limits and JSON schema generation

### Method Callbacks

Expand Down Expand Up @@ -638,6 +639,136 @@ public List<String> completeWithProgress(

This feature enables better tracking and monitoring of MCP operations, especially for long-running tasks that need to report progress back to clients.

#### McpMeta Support

The `McpMeta` class provides access to metadata from MCP requests, notifications, and results. This is useful for accessing contextual information that clients may include with their requests.

When a method parameter is of type `McpMeta`:
- The parameter automatically receives metadata from the request wrapped in an `McpMeta` object
- The parameter is excluded from parameter count limits and JSON schema generation
- The parameter provides convenient access to metadata through the `get(String key)` method
- If no metadata is present in the request, an empty `McpMeta` object is injected

Example usage with tools:

```java
@McpTool(name = "personalized-task", description = "Performs a task with user context")
public String personalizedTask(
@McpToolParam(description = "Task name", required = true) String taskName,
McpMeta meta) {

// Access metadata from the request
String userId = (String) meta.get("userId");
String sessionId = (String) meta.get("sessionId");

if (userId != null) {
return "Task " + taskName + " executed for user: " + userId +
" (session: " + sessionId + ")";
}

return "Task " + taskName + " executed (no user context)";
}

// Tool with both CallToolRequest and McpMeta
@McpTool(name = "flexible-task", description = "Flexible task with metadata")
public CallToolResult flexibleTask(
CallToolRequest request,
McpMeta meta) {

// Access both the full request and metadata
Map<String, Object> args = request.arguments();
String userRole = (String) meta.get("userRole");

String result = "Processed " + args.size() + " arguments";
if (userRole != null) {
result += " for user with role: " + userRole;
}

return CallToolResult.builder()
.addTextContent(result)
.build();
}
```

The `McpMeta` parameter is also supported in other MCP callback types:

**Resource callbacks:**
```java
@McpResource(uri = "user-data://{id}", name = "User Data", description = "User data with context")
public ReadResourceResult getUserData(
String id,
McpMeta meta) {

String requestingUser = (String) meta.get("requestingUser");
String accessLevel = (String) meta.get("accessLevel");

// Use metadata to customize response based on requesting user
String content = "User data for " + id;
if ("admin".equals(accessLevel)) {
content += " (full access granted to " + requestingUser + ")";
} else {
content += " (limited access)";
}

return new ReadResourceResult(List.of(
new TextResourceContents("user-data://" + id, "text/plain", content)
));
}
```

**Prompt callbacks:**
```java
@McpPrompt(name = "contextual-prompt", description = "Generate contextual prompt")
public GetPromptResult contextualPrompt(
@McpArg(name = "topic", required = true) String topic,
McpMeta meta) {

String userPreference = (String) meta.get("preferredStyle");
String language = (String) meta.get("language");

String message = "Let's discuss " + topic;
if ("formal".equals(userPreference)) {
message = "I would like to formally discuss the topic of " + topic;
} else if ("casual".equals(userPreference)) {
message = "Hey! Let's chat about " + topic;
}

if (language != null && !"en".equals(language)) {
message += " (Note: Response requested in " + language + ")";
}

return new GetPromptResult("Contextual Prompt",
List.of(new PromptMessage(Role.ASSISTANT, new TextContent(message))));
}
```

**Complete callbacks:**
```java
@McpComplete(prompt = "smart-complete")
public List<String> smartComplete(
String prefix,
McpMeta meta) {

String userLevel = (String) meta.get("userLevel");
String domain = (String) meta.get("domain");

// Customize completions based on user context
List<String> completions = generateBasicCompletions(prefix);

if ("expert".equals(userLevel)) {
completions.addAll(generateAdvancedCompletions(prefix));
}

if (domain != null) {
completions = filterByDomain(completions, domain);
}

return completions;
}
```

This feature enables context-aware MCP operations where the behavior can be customized based on client-provided metadata such as user identity, preferences, session information, or any other contextual data.

### Async Tool Example

```java
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright 2025-2025 the original author or authors.
*/

package org.springaicommunity.mcp.annotation;

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

import io.modelcontextprotocol.spec.McpSchema;

/**
* Special object used to represent the {@link McpSchema.Request#meta()},
* {@link McpSchema.Notification#meta()} and {@link McpSchema.Result#meta()} values as
* method argument in all client and server MCP request and notification handlers.
*
* @author Christian Tzolov
*/
public record McpMeta(Map<String, Object> meta) {

public McpMeta {
// Ensure idempotent initialization by creating an immutable copy
meta = meta == null ? Collections.emptyMap() : Collections.unmodifiableMap(new HashMap<>(meta));
}

public Object get(String key) {
return meta.get(key);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import org.springaicommunity.mcp.annotation.CompleteAdapter;
import org.springaicommunity.mcp.annotation.McpComplete;
import org.springaicommunity.mcp.annotation.McpMeta;
import org.springaicommunity.mcp.annotation.McpProgressToken;

import io.modelcontextprotocol.spec.McpSchema;
Expand Down Expand Up @@ -127,27 +128,29 @@ protected void validateMethod(Method method) {
protected void validateParameters(Method method) {
Parameter[] parameters = method.getParameters();

// Count non-progress-token parameters
int nonProgressTokenParamCount = 0;
// Count non-special parameters (excluding @McpProgressToken and McpMeta)
int nonSpecialParamCount = 0;
for (Parameter param : parameters) {
if (!param.isAnnotationPresent(McpProgressToken.class)) {
nonProgressTokenParamCount++;
if (!param.isAnnotationPresent(McpProgressToken.class)
&& !McpMeta.class.isAssignableFrom(param.getType())) {
nonSpecialParamCount++;
}
}

// Check parameter count - must have at most 3 non-progress-token parameters
if (nonProgressTokenParamCount > 3) {
// Check parameter count - must have at most 3 non-special parameters
if (nonSpecialParamCount > 3) {
throw new IllegalArgumentException(
"Method can have at most 3 input parameters (excluding @McpProgressToken): " + method.getName()
+ " in " + method.getDeclaringClass().getName() + " has " + nonProgressTokenParamCount
+ " parameters");
"Method can have at most 3 input parameters (excluding @McpProgressToken and McpMeta): "
+ method.getName() + " in " + method.getDeclaringClass().getName() + " has "
+ nonSpecialParamCount + " parameters");
}

// Check parameter types
boolean hasExchangeParam = false;
boolean hasRequestParam = false;
boolean hasArgumentParam = false;
boolean hasProgressTokenParam = false;
boolean hasMetaParam = false;

for (Parameter param : parameters) {
Class<?> paramType = param.getType();
Expand All @@ -162,6 +165,16 @@ protected void validateParameters(Method method) {
continue;
}

// Skip McpMeta parameters from validation
if (McpMeta.class.isAssignableFrom(paramType)) {
if (hasMetaParam) {
throw new IllegalArgumentException("Method cannot have more than one McpMeta parameter: "
+ method.getName() + " in " + method.getDeclaringClass().getName());
}
hasMetaParam = true;
continue;
}

if (isExchangeType(paramType)) {
if (hasExchangeParam) {
throw new IllegalArgumentException("Method cannot have more than one exchange parameter: "
Expand Down Expand Up @@ -206,26 +219,22 @@ protected Object[] buildArgs(Method method, Object exchange, CompleteRequest req
Parameter[] parameters = method.getParameters();
Object[] args = new Object[parameters.length];

// First, handle @McpProgressToken annotated parameters
for (int i = 0; i < parameters.length; i++) {
if (parameters[i].isAnnotationPresent(McpProgressToken.class)) {
Parameter param = parameters[i];
Class<?> paramType = param.getType();

// Handle @McpProgressToken annotated parameters
if (param.isAnnotationPresent(McpProgressToken.class)) {
// CompleteRequest doesn't have a progressToken method in the current spec
// Set to null for now - this would need to be updated when the spec
// supports it
args[i] = null;
}
}

for (int i = 0; i < parameters.length; i++) {
// Skip if already set (e.g., @McpProgressToken)
if (args[i] != null || parameters[i].isAnnotationPresent(McpProgressToken.class)) {
continue;
// Handle McpMeta parameters
else if (McpMeta.class.isAssignableFrom(paramType)) {
args[i] = request != null ? new McpMeta(request.meta()) : new McpMeta(null);
}

Parameter param = parameters[i];
Class<?> paramType = param.getType();

if (isExchangeType(paramType)) {
else if (isExchangeType(paramType)) {
args[i] = exchange;
}
else if (CompleteRequest.class.isAssignableFrom(paramType)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import java.util.Map;

import org.springaicommunity.mcp.annotation.McpArg;
import org.springaicommunity.mcp.annotation.McpMeta;
import org.springaicommunity.mcp.annotation.McpProgressToken;

import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest;
Expand Down Expand Up @@ -89,6 +90,7 @@ protected void validateParameters(Method method) {
boolean hasRequestParam = false;
boolean hasMapParam = false;
boolean hasProgressTokenParam = false;
boolean hasMetaParam = false;

for (java.lang.reflect.Parameter param : parameters) {
Class<?> paramType = param.getType();
Expand All @@ -103,6 +105,16 @@ protected void validateParameters(Method method) {
continue;
}

// Skip McpMeta parameters from validation
if (McpMeta.class.isAssignableFrom(paramType)) {
if (hasMetaParam) {
throw new IllegalArgumentException("Method cannot have more than one McpMeta parameter: "
+ method.getName() + " in " + method.getDeclaringClass().getName());
}
hasMetaParam = true;
continue;
}

if (isExchangeOrContextType(paramType)) {
if (hasExchangeParam) {
throw new IllegalArgumentException("Method cannot have more than one exchange parameter: "
Expand Down Expand Up @@ -153,9 +165,17 @@ protected Object[] buildArgs(Method method, Object exchange, GetPromptRequest re
}
}

// Handle McpMeta parameters
for (int i = 0; i < parameters.length; i++) {
if (McpMeta.class.isAssignableFrom(parameters[i].getType())) {
args[i] = request != null ? new McpMeta(request.meta()) : new McpMeta(null);
}
}

for (int i = 0; i < parameters.length; i++) {
// Skip if already set (e.g., @McpProgressToken)
if (args[i] != null || parameters[i].isAnnotationPresent(McpProgressToken.class)) {
// Skip if already set (e.g., @McpProgressToken, McpMeta)
if (args[i] != null || parameters[i].isAnnotationPresent(McpProgressToken.class)
|| McpMeta.class.isAssignableFrom(parameters[i].getType())) {
continue;
}

Expand Down
Loading