From d4ada72d7e25bcbce3161429707306d00266cd61 Mon Sep 17 00:00:00 2001 From: liugddx Date: Thu, 30 Oct 2025 23:08:19 +0800 Subject: [PATCH 1/2] feat: Add getTextContent method to retrieve message text content Signed-off-by: liugddx --- .../ai/mcp/MetadataBasedToolFilter.java | 298 ++++++++++++++++++ .../ai/tool/ToolCallbackFilters.java | 185 +++++++++++ .../ai/tool/annotation/ToolMetadata.java | 82 +++++ .../definition/DefaultToolDefinition.java | 29 +- .../ai/tool/definition/ToolDefinition.java | 13 + .../ai/tool/support/ToolDefinitions.java | 3 +- .../ai/tool/support/ToolUtils.java | 48 +++ .../ai/tool/ToolCallbackFiltersTest.java | 172 ++++++++++ .../ai/tool/annotation/ToolMetadataTest.java | 149 +++++++++ 9 files changed, 976 insertions(+), 3 deletions(-) create mode 100644 mcp/common/src/main/java/org/springframework/ai/mcp/MetadataBasedToolFilter.java create mode 100644 spring-ai-model/src/main/java/org/springframework/ai/tool/ToolCallbackFilters.java create mode 100644 spring-ai-model/src/main/java/org/springframework/ai/tool/annotation/ToolMetadata.java create mode 100644 spring-ai-model/src/test/java/org/springframework/ai/tool/ToolCallbackFiltersTest.java create mode 100644 spring-ai-model/src/test/java/org/springframework/ai/tool/annotation/ToolMetadataTest.java diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/MetadataBasedToolFilter.java b/mcp/common/src/main/java/org/springframework/ai/mcp/MetadataBasedToolFilter.java new file mode 100644 index 00000000000..04816366f10 --- /dev/null +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/MetadataBasedToolFilter.java @@ -0,0 +1,298 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.mcp; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; + +import io.modelcontextprotocol.spec.McpSchema; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.util.StringUtils; + +/** + * A metadata-based implementation of {@link McpToolFilter} that filters MCP tools based + * on their metadata properties. This filter supports filtering by type, category, + * priority, tags, and custom metadata fields. + * + *

+ * Example usage: + *

+ * + *
+ * // Filter tools by type
+ * var filter = MetadataBasedToolFilter.builder().types(Set.of("RealTimeAnalysis")).build();
+ *
+ * // Filter tools by category and minimum priority
+ * var filter = MetadataBasedToolFilter.builder()
+ *     .categories(Set.of("market", "analytics"))
+ *     .minPriority(7)
+ *     .build();
+ * 
+ * + */ +public class MetadataBasedToolFilter implements McpToolFilter { + + private static final Logger logger = LoggerFactory.getLogger(MetadataBasedToolFilter.class); + + private final Set types; + + private final Set categories; + + private final Set tags; + + private final Integer minPriority; + + private final Integer maxPriority; + + private final Map> customFilters; + + private MetadataBasedToolFilter(Builder builder) { + this.types = builder.types; + this.categories = builder.categories; + this.tags = builder.tags; + this.minPriority = builder.minPriority; + this.maxPriority = builder.maxPriority; + this.customFilters = builder.customFilters; + } + + @Override + public boolean test(McpConnectionInfo connectionInfo, McpSchema.Tool tool) { + // Extract metadata from tool + // Note: MCP Java SDK's Tool might have a meta() method or we need to extract + // metadata from description or other fields + Map metadata = extractMetadataFromTool(tool); + + if (metadata.isEmpty()) { + // If no metadata is present, check if we're filtering anything + // If filters are set, exclude tools without metadata + return types.isEmpty() && categories.isEmpty() && tags.isEmpty() && minPriority == null + && maxPriority == null && customFilters.isEmpty(); + } + + // Filter by type + if (!types.isEmpty()) { + Object typeValue = metadata.get("type"); + if (typeValue == null || !types.contains(typeValue.toString())) { + return false; + } + } + + // Filter by category + if (!categories.isEmpty()) { + Object categoryValue = metadata.get("category"); + if (categoryValue == null || !categories.contains(categoryValue.toString())) { + return false; + } + } + + // Filter by tags + if (!tags.isEmpty()) { + Object tagsValue = metadata.get("tags"); + if (tagsValue == null) { + return false; + } + if (tagsValue instanceof List) { + List toolTags = (List) tagsValue; + boolean hasMatchingTag = toolTags.stream().anyMatch(tag -> tags.contains(tag.toString())); + if (!hasMatchingTag) { + return false; + } + } + else if (!tags.contains(tagsValue.toString())) { + return false; + } + } + + // Filter by priority + if (minPriority != null || maxPriority != null) { + Object priorityValue = metadata.get("priority"); + if (priorityValue != null) { + try { + int priority = priorityValue instanceof Number ? ((Number) priorityValue).intValue() + : Integer.parseInt(priorityValue.toString()); + + if (minPriority != null && priority < minPriority) { + return false; + } + if (maxPriority != null && priority > maxPriority) { + return false; + } + } + catch (NumberFormatException e) { + logger.warn("Invalid priority value in metadata: {}", priorityValue); + return false; + } + } + else { + // No priority metadata, but we require it for filtering + return false; + } + } + + // Apply custom filters + for (Map.Entry> entry : customFilters.entrySet()) { + Object value = metadata.get(entry.getKey()); + if (!entry.getValue().test(value)) { + return false; + } + } + + return true; + } + + /** + * Extracts metadata from an MCP tool. This implementation attempts to extract + * metadata from the tool's description if it follows a specific format: [key1=value1, + * key2=value2] Description text + * + * Subclasses can override this method to implement different metadata extraction + * strategies. + * @param tool the MCP tool + * @return a map of metadata key-value pairs + */ + protected Map extractMetadataFromTool(McpSchema.Tool tool) { + // Try to extract from description if it has metadata prefix + String description = tool.description(); + if (StringUtils.hasText(description) && description.startsWith("[")) { + int endIdx = description.indexOf("]"); + if (endIdx > 0) { + String metadataStr = description.substring(1, endIdx); + return parseMetadataString(metadataStr); + } + } + + // If MCP Tool has a meta() method, try to use it + // This would require reflection or waiting for the MCP SDK to expose it + // For now, return empty map + return Map.of(); + } + + private Map parseMetadataString(String metadataStr) { + Map metadata = new java.util.HashMap<>(); + String[] entries = metadataStr.split(","); + for (String entry : entries) { + String[] parts = entry.split("=", 2); + if (parts.length == 2) { + String key = parts[0].trim(); + String value = parts[1].trim(); + if (StringUtils.hasText(key) && StringUtils.hasText(value)) { + metadata.put(key, value); + } + } + } + return metadata; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private Set types = Set.of(); + + private Set categories = Set.of(); + + private Set tags = Set.of(); + + private Integer minPriority; + + private Integer maxPriority; + + private Map> customFilters = Map.of(); + + private Builder() { + } + + /** + * Set the allowed tool types. + * @param types the types to filter by + * @return this builder + */ + public Builder types(Set types) { + this.types = types != null ? Set.copyOf(types) : Set.of(); + return this; + } + + /** + * Set the allowed tool categories. + * @param categories the categories to filter by + * @return this builder + */ + public Builder categories(Set categories) { + this.categories = categories != null ? Set.copyOf(categories) : Set.of(); + return this; + } + + /** + * Set the required tags (tool must have at least one of these tags). + * @param tags the tags to filter by + * @return this builder + */ + public Builder tags(Set tags) { + this.tags = tags != null ? Set.copyOf(tags) : Set.of(); + return this; + } + + /** + * Set the minimum priority threshold. + * @param minPriority the minimum priority + * @return this builder + */ + public Builder minPriority(Integer minPriority) { + this.minPriority = minPriority; + return this; + } + + /** + * Set the maximum priority threshold. + * @param maxPriority the maximum priority + * @return this builder + */ + public Builder maxPriority(Integer maxPriority) { + this.maxPriority = maxPriority; + return this; + } + + /** + * Add a custom filter for a specific metadata field. + * @param key the metadata key + * @param predicate the predicate to test the value + * @return this builder + */ + public Builder addCustomFilter(String key, Predicate predicate) { + if (this.customFilters.isEmpty()) { + this.customFilters = new java.util.HashMap<>(); + } + else if (!(this.customFilters instanceof java.util.HashMap)) { + this.customFilters = new java.util.HashMap<>(this.customFilters); + } + this.customFilters.put(key, predicate); + return this; + } + + public MetadataBasedToolFilter build() { + return new MetadataBasedToolFilter(this); + } + + } + +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/tool/ToolCallbackFilters.java b/spring-ai-model/src/main/java/org/springframework/ai/tool/ToolCallbackFilters.java new file mode 100644 index 00000000000..84277cb348d --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/tool/ToolCallbackFilters.java @@ -0,0 +1,185 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.tool; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * Utility class for filtering {@link ToolCallback} instances based on metadata and other + * criteria. This class provides various predicate-based filters that can be used to + * select tools dynamically based on their metadata properties. + * + *

+ * Example usage: + *

+ * + *
+ * // Filter tools by type
+ * List<ToolCallback> filteredTools = ToolCallbackFilters.filterByType(allTools, "RealTimeAnalysis");
+ *
+ * // Filter tools by multiple criteria
+ * Predicate<ToolCallback> filter = ToolCallbackFilters.byType("RealTimeAnalysis")
+ *     .and(ToolCallbackFilters.byMinPriority(7));
+ * List<ToolCallback> filtered = allTools.stream().filter(filter).collect(Collectors.toList());
+ * 
+ * + */ +public final class ToolCallbackFilters { + + private ToolCallbackFilters() { + } + + /** + * Creates a predicate that filters tools by their type metadata. + * @param type the required type + * @return a predicate that tests if a tool has the specified type + */ + public static Predicate byType(String type) { + return toolCallback -> { + Map metadata = toolCallback.getToolDefinition().metadata(); + Object typeValue = metadata.get("type"); + return typeValue != null && type.equals(typeValue.toString()); + }; + } + + /** + * Creates a predicate that filters tools by their category metadata. + * @param category the required category + * @return a predicate that tests if a tool has the specified category + */ + public static Predicate byCategory(String category) { + return toolCallback -> { + Map metadata = toolCallback.getToolDefinition().metadata(); + Object categoryValue = metadata.get("category"); + return categoryValue != null && category.equals(categoryValue.toString()); + }; + } + + /** + * Creates a predicate that filters tools by their priority metadata. Only tools with + * priority greater than or equal to the specified minimum are included. + * @param minPriority the minimum priority + * @return a predicate that tests if a tool's priority meets the threshold + */ + public static Predicate byMinPriority(int minPriority) { + return toolCallback -> { + Map metadata = toolCallback.getToolDefinition().metadata(); + Object priorityValue = metadata.get("priority"); + if (priorityValue != null) { + try { + int priority = priorityValue instanceof Number ? ((Number) priorityValue).intValue() + : Integer.parseInt(priorityValue.toString()); + return priority >= minPriority; + } + catch (NumberFormatException e) { + return false; + } + } + return false; + }; + } + + /** + * Creates a predicate that filters tools by their tags metadata. Tools must have at + * least one of the specified tags. + * @param tags the required tags + * @return a predicate that tests if a tool has any of the specified tags + */ + public static Predicate byTags(String... tags) { + Set tagSet = Set.of(tags); + return toolCallback -> { + Map metadata = toolCallback.getToolDefinition().metadata(); + Object tagsValue = metadata.get("tags"); + if (tagsValue instanceof List) { + List toolTags = (List) tagsValue; + return toolTags.stream().anyMatch(tag -> tagSet.contains(tag.toString())); + } + return false; + }; + } + + /** + * Creates a predicate that filters tools by a custom metadata field. + * @param key the metadata key + * @param expectedValue the expected value + * @return a predicate that tests if a tool has the specified metadata value + */ + public static Predicate byMetadata(String key, Object expectedValue) { + return toolCallback -> { + Map metadata = toolCallback.getToolDefinition().metadata(); + Object value = metadata.get(key); + return expectedValue.equals(value); + }; + } + + /** + * Filters a list of tool callbacks by type. + * @param toolCallbacks the list of tool callbacks to filter + * @param type the required type + * @return a filtered list containing only tools with the specified type + */ + public static List filterByType(List toolCallbacks, String type) { + return toolCallbacks.stream().filter(byType(type)).collect(Collectors.toList()); + } + + /** + * Filters an array of tool callbacks by type. + * @param toolCallbacks the array of tool callbacks to filter + * @param type the required type + * @return a filtered array containing only tools with the specified type + */ + public static ToolCallback[] filterByType(ToolCallback[] toolCallbacks, String type) { + return Arrays.stream(toolCallbacks).filter(byType(type)).toArray(ToolCallback[]::new); + } + + /** + * Filters a list of tool callbacks by category. + * @param toolCallbacks the list of tool callbacks to filter + * @param category the required category + * @return a filtered list containing only tools with the specified category + */ + public static List filterByCategory(List toolCallbacks, String category) { + return toolCallbacks.stream().filter(byCategory(category)).collect(Collectors.toList()); + } + + /** + * Filters a list of tool callbacks by minimum priority. + * @param toolCallbacks the list of tool callbacks to filter + * @param minPriority the minimum priority + * @return a filtered list containing only tools with priority >= minPriority + */ + public static List filterByMinPriority(List toolCallbacks, int minPriority) { + return toolCallbacks.stream().filter(byMinPriority(minPriority)).collect(Collectors.toList()); + } + + /** + * Filters a list of tool callbacks by tags. + * @param toolCallbacks the list of tool callbacks to filter + * @param tags the required tags (tool must have at least one) + * @return a filtered list containing only tools with at least one of the specified + * tags + */ + public static List filterByTags(List toolCallbacks, String... tags) { + return toolCallbacks.stream().filter(byTags(tags)).collect(Collectors.toList()); + } + +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/tool/annotation/ToolMetadata.java b/spring-ai-model/src/main/java/org/springframework/ai/tool/annotation/ToolMetadata.java new file mode 100644 index 00000000000..13b3e6ec18f --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/tool/annotation/ToolMetadata.java @@ -0,0 +1,82 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.tool.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to add metadata to a tool method. Can be used in conjunction with + * {@link Tool} annotation. Metadata can be used for filtering, categorization, and other + * purposes when managing large sets of tools. + * + *

+ * Example usage: + *

+ * + *
+ * @Tool(description = "Analyzes real-time market data")
+ * @ToolMetadata(type = "RealTimeAnalysis", category = "market", priority = 8)
+ * public String analyzeMarketData(String symbol) {
+ *     // implementation
+ * }
+ * 
+ * + */ +@Target({ ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ToolMetadata { + + /** + * Additional metadata entries in key=value format. Example: + * {"environment=production", "version=1.0"} + * @return array of metadata key-value pairs + */ + String[] value() default {}; + + /** + * Tool category for classification purposes. Can be used to group related tools + * together. + * @return the category name + */ + String category() default ""; + + /** + * Tool type for filtering purposes. Useful for distinguishing between different kinds + * of operations (e.g., "RealTimeAnalysis", "HistoricalAnalysis"). + * @return the type identifier + */ + String type() default ""; + + /** + * Priority level for tool selection (1-10, where 10 is highest priority). Can be used + * to influence tool selection when multiple tools are available. + * @return the priority level + */ + int priority() default 5; + + /** + * Tags associated with the tool. Useful for flexible categorization and filtering. + * @return array of tags + */ + String[] tags() default {}; + +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/tool/definition/DefaultToolDefinition.java b/spring-ai-model/src/main/java/org/springframework/ai/tool/definition/DefaultToolDefinition.java index cafd1a70364..0b7ed335912 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/tool/definition/DefaultToolDefinition.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/tool/definition/DefaultToolDefinition.java @@ -16,6 +16,10 @@ package org.springframework.ai.tool.definition; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + import org.springframework.ai.util.ParsingUtils; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -26,12 +30,21 @@ * @author Thomas Vitale * @since 1.0.0 */ -public record DefaultToolDefinition(String name, String description, String inputSchema) implements ToolDefinition { +public record DefaultToolDefinition(String name, String description, String inputSchema, + Map metadata) implements ToolDefinition { public DefaultToolDefinition { Assert.hasText(name, "name cannot be null or empty"); Assert.hasText(description, "description cannot be null or empty"); Assert.hasText(inputSchema, "inputSchema cannot be null or empty"); + metadata = Map.copyOf(metadata); + } + + /** + * Constructor for backward compatibility without metadata. + */ + public DefaultToolDefinition(String name, String description, String inputSchema) { + this(name, description, inputSchema, Collections.emptyMap()); } public static Builder builder() { @@ -46,6 +59,8 @@ public static final class Builder { private String inputSchema; + private Map metadata = new HashMap<>(); + private Builder() { } @@ -64,12 +79,22 @@ public Builder inputSchema(String inputSchema) { return this; } + public Builder metadata(Map metadata) { + this.metadata = new HashMap<>(metadata); + return this; + } + + public Builder addMetadata(String key, Object value) { + this.metadata.put(key, value); + return this; + } + public ToolDefinition build() { if (!StringUtils.hasText(this.description)) { Assert.hasText(this.name, "toolName cannot be null or empty"); this.description = ParsingUtils.reConcatenateCamelCase(this.name, " "); } - return new DefaultToolDefinition(this.name, this.description, this.inputSchema); + return new DefaultToolDefinition(this.name, this.description, this.inputSchema, this.metadata); } } diff --git a/spring-ai-model/src/main/java/org/springframework/ai/tool/definition/ToolDefinition.java b/spring-ai-model/src/main/java/org/springframework/ai/tool/definition/ToolDefinition.java index 517a0061712..1db512d2762 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/tool/definition/ToolDefinition.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/tool/definition/ToolDefinition.java @@ -16,6 +16,9 @@ package org.springframework.ai.tool.definition; +import java.util.Collections; +import java.util.Map; + /** * Definition used by the AI model to determine when and how to call the tool. * @@ -39,6 +42,16 @@ public interface ToolDefinition { */ String inputSchema(); + /** + * The metadata associated with the tool. This can be used for filtering, + * categorization, and other purposes. Default implementation returns an empty map. + * @return an unmodifiable map of metadata key-value pairs + * @since 1.0.0 + */ + default Map metadata() { + return Collections.emptyMap(); + } + /** * Create a default {@link ToolDefinition} builder. */ diff --git a/spring-ai-model/src/main/java/org/springframework/ai/tool/support/ToolDefinitions.java b/spring-ai-model/src/main/java/org/springframework/ai/tool/support/ToolDefinitions.java index 68d4646333a..1022ccbf902 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/tool/support/ToolDefinitions.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/tool/support/ToolDefinitions.java @@ -49,7 +49,8 @@ public static DefaultToolDefinition.Builder builder(Method method) { return DefaultToolDefinition.builder() .name(ToolUtils.getToolName(method)) .description(ToolUtils.getToolDescription(method)) - .inputSchema(JsonSchemaGenerator.generateForMethodInput(method)); + .inputSchema(JsonSchemaGenerator.generateForMethodInput(method)) + .metadata(ToolUtils.getToolMetadata(method)); } /** diff --git a/spring-ai-model/src/main/java/org/springframework/ai/tool/support/ToolUtils.java b/spring-ai-model/src/main/java/org/springframework/ai/tool/support/ToolUtils.java index baa03b830cb..061ce4c2b76 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/tool/support/ToolUtils.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/tool/support/ToolUtils.java @@ -18,6 +18,7 @@ import java.lang.reflect.Method; import java.util.Arrays; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.regex.Pattern; @@ -28,6 +29,7 @@ import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolMetadata; import org.springframework.ai.tool.execution.DefaultToolCallResultConverter; import org.springframework.ai.tool.execution.ToolCallResultConverter; import org.springframework.ai.util.ParsingUtils; @@ -120,6 +122,52 @@ public static List getDuplicateToolNames(ToolCallback... toolCallbacks) return getDuplicateToolNames(Arrays.asList(toolCallbacks)); } + /** + * Extracts metadata from a method annotated with {@link ToolMetadata}. + * @param method the method to extract metadata from + * @return a map of metadata key-value pairs, or an empty map if no metadata is + * present + */ + public static Map getToolMetadata(Method method) { + Assert.notNull(method, "method cannot be null"); + var toolMetadata = AnnotatedElementUtils.findMergedAnnotation(method, ToolMetadata.class); + if (toolMetadata == null) { + return Map.of(); + } + + Map metadata = new HashMap<>(); + + if (StringUtils.hasText(toolMetadata.type())) { + metadata.put("type", toolMetadata.type()); + } + + if (StringUtils.hasText(toolMetadata.category())) { + metadata.put("category", toolMetadata.category()); + } + + metadata.put("priority", toolMetadata.priority()); + + if (toolMetadata.tags().length > 0) { + metadata.put("tags", Arrays.asList(toolMetadata.tags())); + } + + for (String entry : toolMetadata.value()) { + String[] parts = entry.split("=", 2); + if (parts.length == 2) { + String key = parts[0].trim(); + String value = parts[1].trim(); + if (StringUtils.hasText(key) && StringUtils.hasText(value)) { + metadata.put(key, value); + } + } + else { + logger.warn("Invalid metadata entry format: '{}'. Expected format is 'key=value'.", entry); + } + } + + return metadata; + } + /** * Validates that a tool name follows recommended naming conventions. Logs a warning * if the tool name contains characters that may not be compatible with some LLMs. diff --git a/spring-ai-model/src/test/java/org/springframework/ai/tool/ToolCallbackFiltersTest.java b/spring-ai-model/src/test/java/org/springframework/ai/tool/ToolCallbackFiltersTest.java new file mode 100644 index 00000000000..603fe3a8b7b --- /dev/null +++ b/spring-ai-model/src/test/java/org/springframework/ai/tool/ToolCallbackFiltersTest.java @@ -0,0 +1,172 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.tool; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolMetadata; +import org.springframework.ai.tool.method.MethodToolCallback; +import org.springframework.ai.tool.support.ToolDefinitions; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ToolCallbackFilters}. + * + */ +class ToolCallbackFiltersTest { + + private List allTools; + + @BeforeEach + void setUp() throws Exception { + TestToolsService service = new TestToolsService(); + + // Create tool callbacks for test methods + ToolCallback realTimeAnalysis = MethodToolCallback.builder() + .toolDefinition(ToolDefinitions.from(TestToolsService.class.getMethod("realTimeAnalysis", String.class))) + .toolMethod(TestToolsService.class.getMethod("realTimeAnalysis", String.class)) + .toolObject(service) + .build(); + + ToolCallback historicalAnalysis = MethodToolCallback.builder() + .toolDefinition(ToolDefinitions.from(TestToolsService.class.getMethod("historicalAnalysis", String.class))) + .toolMethod(TestToolsService.class.getMethod("historicalAnalysis", String.class)) + .toolObject(service) + .build(); + + ToolCallback reportGeneration = MethodToolCallback.builder() + .toolDefinition(ToolDefinitions.from(TestToolsService.class.getMethod("reportGeneration", String.class))) + .toolMethod(TestToolsService.class.getMethod("reportGeneration", String.class)) + .toolObject(service) + .build(); + + ToolCallback dataValidation = MethodToolCallback.builder() + .toolDefinition(ToolDefinitions.from(TestToolsService.class.getMethod("dataValidation", String.class))) + .toolMethod(TestToolsService.class.getMethod("dataValidation", String.class)) + .toolObject(service) + .build(); + + this.allTools = List.of(realTimeAnalysis, historicalAnalysis, reportGeneration, dataValidation); + } + + @Test + void shouldFilterByType() { + List filtered = ToolCallbackFilters.filterByType(this.allTools, "RealTimeAnalysis"); + + assertThat(filtered).hasSize(1); + assertThat(filtered.get(0).getToolDefinition().name()).isEqualTo("realTimeAnalysis"); + } + + @Test + void shouldFilterByCategory() { + List filtered = ToolCallbackFilters.filterByCategory(this.allTools, "analytics"); + + assertThat(filtered).hasSize(2); + assertThat(filtered).extracting(tc -> tc.getToolDefinition().name()) + .containsExactlyInAnyOrder("realTimeAnalysis", "historicalAnalysis"); + } + + @Test + void shouldFilterByMinPriority() { + List filtered = ToolCallbackFilters.filterByMinPriority(this.allTools, 7); + + assertThat(filtered).hasSize(2); + assertThat(filtered).extracting(tc -> tc.getToolDefinition().name()) + .containsExactlyInAnyOrder("realTimeAnalysis", "reportGeneration"); + } + + @Test + void shouldFilterByTags() { + List filtered = ToolCallbackFilters.filterByTags(this.allTools, "critical"); + + assertThat(filtered).hasSize(2); + assertThat(filtered).extracting(tc -> tc.getToolDefinition().name()) + .containsExactlyInAnyOrder("realTimeAnalysis", "dataValidation"); + } + + @Test + void shouldFilterByCustomMetadata() { + List filtered = this.allTools.stream() + .filter(ToolCallbackFilters.byMetadata("environment", "production")) + .toList(); + + assertThat(filtered).hasSize(1); + assertThat(filtered.get(0).getToolDefinition().name()).isEqualTo("realTimeAnalysis"); + } + + @Test + void shouldCombineMultipleFilters() { + List filtered = this.allTools.stream() + .filter(ToolCallbackFilters.byCategory("analytics").and(ToolCallbackFilters.byMinPriority(7))) + .toList(); + + assertThat(filtered).hasSize(1); + assertThat(filtered.get(0).getToolDefinition().name()).isEqualTo("realTimeAnalysis"); + } + + @Test + void shouldReturnEmptyListWhenNoMatch() { + List filtered = ToolCallbackFilters.filterByType(this.allTools, "NonExistentType"); + + assertThat(filtered).isEmpty(); + } + + @Test + void shouldFilterArrayByType() { + ToolCallback[] array = this.allTools.toArray(new ToolCallback[0]); + ToolCallback[] filtered = ToolCallbackFilters.filterByType(array, "RealTimeAnalysis"); + + assertThat(filtered).hasSize(1); + assertThat(filtered[0].getToolDefinition().name()).isEqualTo("realTimeAnalysis"); + } + + // Test tools service + static class TestToolsService { + + @Tool(description = "Analyzes real-time market data") + @ToolMetadata(type = "RealTimeAnalysis", category = "analytics", priority = 8, + tags = { "critical", "realtime" }, value = { "environment=production" }) + public String realTimeAnalysis(String symbol) { + return "Real-time analysis for " + symbol; + } + + @Tool(description = "Analyzes historical trends") + @ToolMetadata(type = "HistoricalAnalysis", category = "analytics", priority = 6, tags = { "historical" }) + public String historicalAnalysis(String period) { + return "Historical analysis for " + period; + } + + @Tool(description = "Generates reports") + @ToolMetadata(type = "Reporting", category = "reporting", priority = 7, tags = { "reporting" }) + public String reportGeneration(String type) { + return "Report: " + type; + } + + @Tool(description = "Validates data") + @ToolMetadata(type = "Validation", category = "quality", priority = 5, tags = { "critical", "validation" }) + public String dataValidation(String data) { + return "Validation result for " + data; + } + + } + +} diff --git a/spring-ai-model/src/test/java/org/springframework/ai/tool/annotation/ToolMetadataTest.java b/spring-ai-model/src/test/java/org/springframework/ai/tool/annotation/ToolMetadataTest.java new file mode 100644 index 00000000000..7a8435c118a --- /dev/null +++ b/spring-ai-model/src/test/java/org/springframework/ai/tool/annotation/ToolMetadataTest.java @@ -0,0 +1,149 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.tool.annotation; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.support.ToolDefinitions; +import org.springframework.ai.tool.support.ToolUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ToolMetadata} annotation and metadata extraction. + * + */ +class ToolMetadataTest { + + @Test + void shouldExtractMetadataFromAnnotation() throws Exception { + Method method = TestTools.class.getMethod("realTimeAnalysis", String.class); + Map metadata = ToolUtils.getToolMetadata(method); + + assertThat(metadata).isNotEmpty(); + assertThat(metadata.get("type")).isEqualTo("RealTimeAnalysis"); + assertThat(metadata.get("category")).isEqualTo("market"); + assertThat(metadata.get("priority")).isEqualTo(8); + } + + @Test + void shouldExtractTagsFromAnnotation() throws Exception { + Method method = TestTools.class.getMethod("historicalAnalysis", String.class); + Map metadata = ToolUtils.getToolMetadata(method); + + assertThat(metadata).isNotEmpty(); + assertThat(metadata.get("tags")).isInstanceOf(List.class); + @SuppressWarnings("unchecked") + List tags = (List) metadata.get("tags"); + assertThat(tags).containsExactlyInAnyOrder("historical", "longterm"); + } + + @Test + void shouldExtractMultipleMetadataFields() throws Exception { + Method method = TestTools.class.getMethod("customTool"); + Map metadata = ToolUtils.getToolMetadata(method); + + assertThat(metadata).isNotEmpty(); + assertThat(metadata.get("type")).isEqualTo("CustomType"); + assertThat(metadata.get("category")).isEqualTo("custom"); + } + + @Test + void shouldReturnEmptyMapWhenNoMetadata() throws Exception { + Method method = TestTools.class.getMethod("noMetadataTool"); + Map metadata = ToolUtils.getToolMetadata(method); + + assertThat(metadata).isEmpty(); + } + + @Test + void shouldIncludeMetadataInToolDefinition() throws Exception { + Method method = TestTools.class.getMethod("realTimeAnalysis", String.class); + ToolDefinition toolDefinition = ToolDefinitions.from(method); + + assertThat(toolDefinition.metadata()).isNotEmpty(); + assertThat(toolDefinition.metadata().get("type")).isEqualTo("RealTimeAnalysis"); + assertThat(toolDefinition.metadata().get("category")).isEqualTo("market"); + assertThat(toolDefinition.metadata().get("priority")).isEqualTo(8); + } + + @Test + void shouldHandleDefaultPriority() throws Exception { + Method method = TestTools.class.getMethod("defaultPriorityTool"); + Map metadata = ToolUtils.getToolMetadata(method); + + assertThat(metadata.get("priority")).isEqualTo(5); // Default priority + } + + @Test + void shouldHandlePartialMetadata() throws Exception { + Method method = TestTools.class.getMethod("partialMetadataTool"); + Map metadata = ToolUtils.getToolMetadata(method); + + assertThat(metadata).isNotEmpty(); + assertThat(metadata.get("type")).isEqualTo("PartialType"); + assertThat(metadata.get("category")).isNull(); + assertThat(metadata.get("priority")).isEqualTo(5); // Default + } + + // Test tools class + static class TestTools { + + @Tool(description = "Analyzes real-time market data") + @ToolMetadata(type = "RealTimeAnalysis", category = "market", priority = 8) + public String realTimeAnalysis(String symbol) { + return "Real-time analysis for " + symbol; + } + + @Tool(description = "Analyzes historical trends") + @ToolMetadata(type = "HistoricalAnalysis", category = "market", priority = 6, + tags = { "historical", "longterm" }) + public String historicalAnalysis(String period) { + return "Historical analysis for " + period; + } + + @Tool(description = "Custom tool with additional metadata") + @ToolMetadata(type = "CustomType", category = "custom") + public String customTool() { + return "Custom result"; + } + + @Tool(description = "Tool without metadata") + public String noMetadataTool() { + return "No metadata"; + } + + @Tool(description = "Tool with default priority") + @ToolMetadata(category = "test") + public String defaultPriorityTool() { + return "Default priority"; + } + + @Tool(description = "Tool with partial metadata") + @ToolMetadata(type = "PartialType") + public String partialMetadataTool() { + return "Partial metadata"; + } + + } + +} From c203dad7f6eed40c4e1e74f1c5496c7d255994b5 Mon Sep 17 00:00:00 2001 From: liugddx Date: Fri, 31 Oct 2025 21:33:14 +0800 Subject: [PATCH 2/2] feat: Add getTextContent method to retrieve message text content Signed-off-by: liugddx --- .../ai/mcp/MetadataBasedToolFilter.java | 298 ------------------ 1 file changed, 298 deletions(-) delete mode 100644 mcp/common/src/main/java/org/springframework/ai/mcp/MetadataBasedToolFilter.java diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/MetadataBasedToolFilter.java b/mcp/common/src/main/java/org/springframework/ai/mcp/MetadataBasedToolFilter.java deleted file mode 100644 index 04816366f10..00000000000 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/MetadataBasedToolFilter.java +++ /dev/null @@ -1,298 +0,0 @@ -/* - * Copyright 2025-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.ai.mcp; - -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.function.Predicate; - -import io.modelcontextprotocol.spec.McpSchema; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import org.springframework.util.StringUtils; - -/** - * A metadata-based implementation of {@link McpToolFilter} that filters MCP tools based - * on their metadata properties. This filter supports filtering by type, category, - * priority, tags, and custom metadata fields. - * - *

- * Example usage: - *

- * - *
- * // Filter tools by type
- * var filter = MetadataBasedToolFilter.builder().types(Set.of("RealTimeAnalysis")).build();
- *
- * // Filter tools by category and minimum priority
- * var filter = MetadataBasedToolFilter.builder()
- *     .categories(Set.of("market", "analytics"))
- *     .minPriority(7)
- *     .build();
- * 
- * - */ -public class MetadataBasedToolFilter implements McpToolFilter { - - private static final Logger logger = LoggerFactory.getLogger(MetadataBasedToolFilter.class); - - private final Set types; - - private final Set categories; - - private final Set tags; - - private final Integer minPriority; - - private final Integer maxPriority; - - private final Map> customFilters; - - private MetadataBasedToolFilter(Builder builder) { - this.types = builder.types; - this.categories = builder.categories; - this.tags = builder.tags; - this.minPriority = builder.minPriority; - this.maxPriority = builder.maxPriority; - this.customFilters = builder.customFilters; - } - - @Override - public boolean test(McpConnectionInfo connectionInfo, McpSchema.Tool tool) { - // Extract metadata from tool - // Note: MCP Java SDK's Tool might have a meta() method or we need to extract - // metadata from description or other fields - Map metadata = extractMetadataFromTool(tool); - - if (metadata.isEmpty()) { - // If no metadata is present, check if we're filtering anything - // If filters are set, exclude tools without metadata - return types.isEmpty() && categories.isEmpty() && tags.isEmpty() && minPriority == null - && maxPriority == null && customFilters.isEmpty(); - } - - // Filter by type - if (!types.isEmpty()) { - Object typeValue = metadata.get("type"); - if (typeValue == null || !types.contains(typeValue.toString())) { - return false; - } - } - - // Filter by category - if (!categories.isEmpty()) { - Object categoryValue = metadata.get("category"); - if (categoryValue == null || !categories.contains(categoryValue.toString())) { - return false; - } - } - - // Filter by tags - if (!tags.isEmpty()) { - Object tagsValue = metadata.get("tags"); - if (tagsValue == null) { - return false; - } - if (tagsValue instanceof List) { - List toolTags = (List) tagsValue; - boolean hasMatchingTag = toolTags.stream().anyMatch(tag -> tags.contains(tag.toString())); - if (!hasMatchingTag) { - return false; - } - } - else if (!tags.contains(tagsValue.toString())) { - return false; - } - } - - // Filter by priority - if (minPriority != null || maxPriority != null) { - Object priorityValue = metadata.get("priority"); - if (priorityValue != null) { - try { - int priority = priorityValue instanceof Number ? ((Number) priorityValue).intValue() - : Integer.parseInt(priorityValue.toString()); - - if (minPriority != null && priority < minPriority) { - return false; - } - if (maxPriority != null && priority > maxPriority) { - return false; - } - } - catch (NumberFormatException e) { - logger.warn("Invalid priority value in metadata: {}", priorityValue); - return false; - } - } - else { - // No priority metadata, but we require it for filtering - return false; - } - } - - // Apply custom filters - for (Map.Entry> entry : customFilters.entrySet()) { - Object value = metadata.get(entry.getKey()); - if (!entry.getValue().test(value)) { - return false; - } - } - - return true; - } - - /** - * Extracts metadata from an MCP tool. This implementation attempts to extract - * metadata from the tool's description if it follows a specific format: [key1=value1, - * key2=value2] Description text - * - * Subclasses can override this method to implement different metadata extraction - * strategies. - * @param tool the MCP tool - * @return a map of metadata key-value pairs - */ - protected Map extractMetadataFromTool(McpSchema.Tool tool) { - // Try to extract from description if it has metadata prefix - String description = tool.description(); - if (StringUtils.hasText(description) && description.startsWith("[")) { - int endIdx = description.indexOf("]"); - if (endIdx > 0) { - String metadataStr = description.substring(1, endIdx); - return parseMetadataString(metadataStr); - } - } - - // If MCP Tool has a meta() method, try to use it - // This would require reflection or waiting for the MCP SDK to expose it - // For now, return empty map - return Map.of(); - } - - private Map parseMetadataString(String metadataStr) { - Map metadata = new java.util.HashMap<>(); - String[] entries = metadataStr.split(","); - for (String entry : entries) { - String[] parts = entry.split("=", 2); - if (parts.length == 2) { - String key = parts[0].trim(); - String value = parts[1].trim(); - if (StringUtils.hasText(key) && StringUtils.hasText(value)) { - metadata.put(key, value); - } - } - } - return metadata; - } - - public static Builder builder() { - return new Builder(); - } - - public static final class Builder { - - private Set types = Set.of(); - - private Set categories = Set.of(); - - private Set tags = Set.of(); - - private Integer minPriority; - - private Integer maxPriority; - - private Map> customFilters = Map.of(); - - private Builder() { - } - - /** - * Set the allowed tool types. - * @param types the types to filter by - * @return this builder - */ - public Builder types(Set types) { - this.types = types != null ? Set.copyOf(types) : Set.of(); - return this; - } - - /** - * Set the allowed tool categories. - * @param categories the categories to filter by - * @return this builder - */ - public Builder categories(Set categories) { - this.categories = categories != null ? Set.copyOf(categories) : Set.of(); - return this; - } - - /** - * Set the required tags (tool must have at least one of these tags). - * @param tags the tags to filter by - * @return this builder - */ - public Builder tags(Set tags) { - this.tags = tags != null ? Set.copyOf(tags) : Set.of(); - return this; - } - - /** - * Set the minimum priority threshold. - * @param minPriority the minimum priority - * @return this builder - */ - public Builder minPriority(Integer minPriority) { - this.minPriority = minPriority; - return this; - } - - /** - * Set the maximum priority threshold. - * @param maxPriority the maximum priority - * @return this builder - */ - public Builder maxPriority(Integer maxPriority) { - this.maxPriority = maxPriority; - return this; - } - - /** - * Add a custom filter for a specific metadata field. - * @param key the metadata key - * @param predicate the predicate to test the value - * @return this builder - */ - public Builder addCustomFilter(String key, Predicate predicate) { - if (this.customFilters.isEmpty()) { - this.customFilters = new java.util.HashMap<>(); - } - else if (!(this.customFilters instanceof java.util.HashMap)) { - this.customFilters = new java.util.HashMap<>(this.customFilters); - } - this.customFilters.put(key, predicate); - return this; - } - - public MetadataBasedToolFilter build() { - return new MetadataBasedToolFilter(this); - } - - } - -}