forked from kimhyungsik/ax_hub_mcp_tool
fix: align tool validation and source updates with MCP SDK
This commit is contained in:
@@ -25,6 +25,7 @@ import java.lang.annotation.Target;
|
|||||||
public @interface ToolHint {
|
public @interface ToolHint {
|
||||||
boolean register() default false;
|
boolean register() default false;
|
||||||
boolean requiresApproval() default false;
|
boolean requiresApproval() default false;
|
||||||
|
String group() default "";
|
||||||
String mappingId() default "";
|
String mappingId() default "";
|
||||||
String inputSchemaResource() default "";
|
String inputSchemaResource() default "";
|
||||||
String outputSchemaResource() default "";
|
String outputSchemaResource() default "";
|
||||||
|
|||||||
@@ -107,7 +107,8 @@ public class ToolRegistryHeartbeatSender {
|
|||||||
meta.setTimeoutMillis(5000L);
|
meta.setTimeoutMillis(5000L);
|
||||||
meta.setEnabled(true);
|
meta.setEnabled(true);
|
||||||
meta.setDescription(functionAnnotation.description());
|
meta.setDescription(functionAnnotation.description());
|
||||||
meta.setCategoryKey("default");
|
meta.setCategoryKey(hintAnnotation == null || hintAnnotation.group().isBlank()
|
||||||
|
? "default" : hintAnnotation.group());
|
||||||
meta.setIntegrationType("REST");
|
meta.setIntegrationType("REST");
|
||||||
meta.setMciServiceId(hintAnnotation != null ? hintAnnotation.mappingId() : "");
|
meta.setMciServiceId(hintAnnotation != null ? hintAnnotation.mappingId() : "");
|
||||||
meta.setPodUrl(podUrl);
|
meta.setPodUrl(podUrl);
|
||||||
|
|||||||
@@ -1,123 +1,140 @@
|
|||||||
package io.shinhanlife.dap.lib.util;
|
package io.shinhanlife.dap.lib.util;
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @package io.shinhanlife.dap.lib.util
|
|
||||||
* @className ToolSourceUpdater
|
|
||||||
* @description AX HUB 시스템 처리 클래스
|
|
||||||
* @author 0986406
|
|
||||||
* @create 2026.09.01
|
|
||||||
* <pre>
|
|
||||||
* ---------- 개정이력 ----------
|
|
||||||
* 수정일 수정자 수정내용
|
|
||||||
* ---------- -------- ---------------------------
|
|
||||||
* 2026.09.01 0986406 최초생성
|
|
||||||
*
|
|
||||||
* </pre>
|
|
||||||
*/
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.util.List;
|
import java.util.Comparator;
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import java.util.stream.Stream;
|
|
||||||
|
|
||||||
public class ToolSourceUpdater {
|
/** Updates MCP SDK and project-owned metadata in a generated tool source file. */
|
||||||
|
public final class ToolSourceUpdater {
|
||||||
|
|
||||||
public static void updateToolSource(String toolName, String domainGroup, String description, boolean register, Boolean requiresApproval) throws Exception {
|
private ToolSourceUpdater() {
|
||||||
// 1. Find all *UseCase.java files in dap-was-* directories
|
}
|
||||||
String envSourceDir = System.getenv("AXHUB_SOURCE_DIR");
|
|
||||||
Path rootDir = envSourceDir != null ? Paths.get(envSourceDir) : Paths.get(".");
|
|
||||||
|
|
||||||
List<Path> javaFiles;
|
|
||||||
try (Stream<Path> paths = Files.walk(rootDir)) {
|
|
||||||
javaFiles = paths
|
|
||||||
.filter(Files::isRegularFile)
|
|
||||||
.filter(p -> p.toString().endsWith("UseCase.java"))
|
|
||||||
.filter(p -> p.toString().contains("dap-was-") || p.toString().contains("axhub-tool-"))
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
Path targetFile = null;
|
public static void updateToolSource(String toolName, String domainGroup, String description,
|
||||||
String content = null;
|
boolean register, Boolean requiresApproval) throws IOException {
|
||||||
|
String configuredSourceDirectory = System.getenv("AXHUB_SOURCE_DIR");
|
||||||
// 2. Find the specific file for the tool
|
Path rootDirectory = configuredSourceDirectory == null || configuredSourceDirectory.isBlank()
|
||||||
String functionName = toolName;
|
? Paths.get(".") : Paths.get(configuredSourceDirectory);
|
||||||
if (toolName.contains("_")) {
|
updateToolSource(rootDirectory, toolName, domainGroup, description, register, requiresApproval);
|
||||||
functionName = toolName.substring(toolName.indexOf("_") + 1);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Pattern namePattern = Pattern.compile("@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\"", Pattern.DOTALL);
|
|
||||||
Pattern namePattern2 = Pattern.compile("@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(functionName) + "\"", Pattern.DOTALL);
|
|
||||||
|
|
||||||
for (Path path : javaFiles) {
|
|
||||||
String text = Files.readString(path);
|
|
||||||
if (namePattern.matcher(text).find()) {
|
|
||||||
targetFile = path;
|
|
||||||
content = text;
|
|
||||||
break;
|
|
||||||
} else if (namePattern2.matcher(text).find()) {
|
|
||||||
targetFile = path;
|
|
||||||
content = text;
|
|
||||||
toolName = functionName; // Use baseName for subsequent replacements
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
static void updateToolSource(Path rootDirectory, String toolName, String domainGroup, String description,
|
||||||
|
boolean register, Boolean requiresApproval) throws IOException {
|
||||||
|
Path targetFile = findToolSource(rootDirectory, toolName);
|
||||||
if (targetFile == null) {
|
if (targetFile == null) {
|
||||||
throw new Exception("소스 코드를 찾을 수 없습니다: " + toolName);
|
throw new IllegalArgumentException("Tool source not found: " + toolName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Update @McpTool group
|
String content = Files.readString(targetFile);
|
||||||
if (domainGroup != null && !domainGroup.trim().isEmpty()) {
|
content = updateMcpTool(content, toolName, description);
|
||||||
Pattern groupPattern = Pattern.compile("(@McpTool\\s*\\([^)]*group\\s*=\\s*\")([^\"]+)(\")", Pattern.DOTALL);
|
content = updateToolHint(content, domainGroup, register, requiresApproval);
|
||||||
Matcher groupMatcher = groupPattern.matcher(content);
|
|
||||||
if (groupMatcher.find()) {
|
|
||||||
content = groupMatcher.replaceFirst("$1" + domainGroup + "$3");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Update @McpFunction description
|
|
||||||
if (description != null) {
|
|
||||||
Pattern funcPattern = Pattern.compile("(@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\"[^)]*description\\s*=\\s*\")([^\"]+)(\")", Pattern.DOTALL);
|
|
||||||
Matcher funcMatcher = funcPattern.matcher(content);
|
|
||||||
if (funcMatcher.find()) {
|
|
||||||
content = funcMatcher.replaceFirst("$1" + description.replace("\\", "\\\\").replace("$", "\\\\$") + "$3");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Update register flag
|
|
||||||
Pattern regPattern = Pattern.compile("(@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\"[^)]*register\\s*=\\s*)(true|false)([^a-zA-Z0-9])", Pattern.DOTALL);
|
|
||||||
Matcher regMatcher = regPattern.matcher(content);
|
|
||||||
if (regMatcher.find()) {
|
|
||||||
content = regMatcher.replaceFirst("$1" + register + "$3");
|
|
||||||
} else {
|
|
||||||
Pattern addRegPattern = Pattern.compile("(@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\")", Pattern.DOTALL);
|
|
||||||
Matcher addRegMatcher = addRegPattern.matcher(content);
|
|
||||||
if (addRegMatcher.find()) {
|
|
||||||
content = addRegMatcher.replaceFirst("$1, register = " + register);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5.5 Update requiresApproval flag
|
|
||||||
if (requiresApproval != null) {
|
|
||||||
Pattern appPattern = Pattern.compile("(@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\"[^)]*requiresApproval\\s*=\\s*)(true|false)([^a-zA-Z0-9])", Pattern.DOTALL);
|
|
||||||
Matcher appMatcher = appPattern.matcher(content);
|
|
||||||
if (appMatcher.find()) {
|
|
||||||
content = appMatcher.replaceFirst("$1" + requiresApproval + "$3");
|
|
||||||
} else {
|
|
||||||
Pattern addAppPattern = Pattern.compile("(@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\")", Pattern.DOTALL);
|
|
||||||
Matcher addAppMatcher = addAppPattern.matcher(content);
|
|
||||||
if (addAppMatcher.find()) {
|
|
||||||
content = addAppMatcher.replaceFirst("$1, requiresApproval = " + requiresApproval);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Write back to file
|
|
||||||
Files.writeString(targetFile, content);
|
Files.writeString(targetFile, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Path findToolSource(Path rootDirectory, String toolName) throws IOException {
|
||||||
|
try (var paths = Files.walk(rootDirectory)) {
|
||||||
|
return paths.filter(Files::isRegularFile)
|
||||||
|
.filter(path -> path.getFileName().toString().endsWith("UseCase.java"))
|
||||||
|
.filter(path -> path.toString().contains("dap-was-") || path.toString().contains("axhub-tool-"))
|
||||||
|
.sorted(Comparator.naturalOrder())
|
||||||
|
.filter(path -> containsMcpTool(path, toolName))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean containsMcpTool(Path path, String toolName) {
|
||||||
|
try {
|
||||||
|
return annotationArguments(Files.readString(path), "McpTool", toolName) != null;
|
||||||
|
} catch (IOException exception) {
|
||||||
|
throw new IllegalStateException("Failed to read tool source: " + path, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String updateMcpTool(String content, String toolName, String description) {
|
||||||
|
AnnotationRange range = annotationArguments(content, "McpTool", toolName);
|
||||||
|
if (range == null) {
|
||||||
|
throw new IllegalArgumentException("McpTool declaration not found: " + toolName);
|
||||||
|
}
|
||||||
|
return description == null ? content : replaceAttribute(content, range, "description", quote(description));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String updateToolHint(String content, String domainGroup, boolean register, Boolean requiresApproval) {
|
||||||
|
AnnotationRange range = annotationArguments(content, "ToolHint", null);
|
||||||
|
if (range == null) {
|
||||||
|
throw new IllegalArgumentException("ToolHint declaration not found next to McpTool");
|
||||||
|
}
|
||||||
|
String updated = replaceAttribute(content, range, "register", Boolean.toString(register));
|
||||||
|
range = annotationArguments(updated, "ToolHint", null);
|
||||||
|
if (requiresApproval != null) {
|
||||||
|
updated = replaceAttribute(updated, range, "requiresApproval", Boolean.toString(requiresApproval));
|
||||||
|
range = annotationArguments(updated, "ToolHint", null);
|
||||||
|
}
|
||||||
|
if (domainGroup != null && !domainGroup.isBlank()) {
|
||||||
|
updated = replaceAttribute(updated, range, "group", quote(domainGroup));
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String replaceAttribute(String content, AnnotationRange range, String attribute, String value) {
|
||||||
|
String arguments = content.substring(range.argumentsStart(), range.argumentsEnd());
|
||||||
|
String pattern = "\\b" + attribute + "\\s*=\\s*(?:true|false|\\\"(?:\\\\.|[^\\\"\\\\])*\\\")";
|
||||||
|
String replacement = arguments.replaceFirst(pattern, attribute + " = " + value);
|
||||||
|
if (replacement.equals(arguments)) {
|
||||||
|
replacement = arguments.isBlank() ? attribute + " = " + value : arguments + ", " + attribute + " = " + value;
|
||||||
|
}
|
||||||
|
return content.substring(0, range.argumentsStart()) + replacement + content.substring(range.argumentsEnd());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String quote(String value) {
|
||||||
|
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AnnotationRange annotationArguments(String content, String annotationName, String toolName) {
|
||||||
|
int offset = content.indexOf("@" + annotationName);
|
||||||
|
while (offset >= 0) {
|
||||||
|
int openingParenthesis = content.indexOf('(', offset);
|
||||||
|
int closingParenthesis = findAnnotationEnd(content, openingParenthesis);
|
||||||
|
if (openingParenthesis < 0 || closingParenthesis < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
AnnotationRange range = new AnnotationRange(openingParenthesis + 1, closingParenthesis);
|
||||||
|
if (toolName == null || content.substring(range.argumentsStart(), range.argumentsEnd())
|
||||||
|
.matches("(?s).*\\bname\\s*=\\s*\\\"" + java.util.regex.Pattern.quote(toolName) + "\\\".*")) {
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
offset = content.indexOf("@" + annotationName, closingParenthesis + 1);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int findAnnotationEnd(String content, int openingParenthesis) {
|
||||||
|
int depth = 0;
|
||||||
|
boolean inString = false;
|
||||||
|
boolean escaped = false;
|
||||||
|
for (int index = openingParenthesis; index < content.length(); index++) {
|
||||||
|
char character = content.charAt(index);
|
||||||
|
if (inString) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
} else if (character == '\\') {
|
||||||
|
escaped = true;
|
||||||
|
} else if (character == '\"') {
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
} else if (character == '\"') {
|
||||||
|
inString = true;
|
||||||
|
} else if (character == '(') {
|
||||||
|
depth++;
|
||||||
|
} else if (character == ')' && --depth == 0) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record AnnotationRange(int argumentsStart, int argumentsEnd) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import java.util.regex.Pattern;
|
|||||||
/**
|
/**
|
||||||
* @package io.shinhanlife.dap.lib.validation
|
* @package io.shinhanlife.dap.lib.validation
|
||||||
* @className McpToolNameValidator
|
* @className McpToolNameValidator
|
||||||
* @description Validates unique MCP function names across tool modules
|
* @description Validates unique MCP SDK tool names across tool modules
|
||||||
* @author 0986406
|
* @author 0986406
|
||||||
* @create 2026.07.27
|
* @create 2026.07.27
|
||||||
* <pre>
|
* <pre>
|
||||||
@@ -88,7 +88,7 @@ public final class McpToolNameValidator {
|
|||||||
throw new UncheckedIOException("Failed to read " + source, exception);
|
throw new UncheckedIOException("Failed to read " + source, exception);
|
||||||
}
|
}
|
||||||
|
|
||||||
int annotationOffset = content.indexOf("@McpFunction");
|
int annotationOffset = content.indexOf("@McpTool");
|
||||||
while (annotationOffset >= 0) {
|
while (annotationOffset >= 0) {
|
||||||
int openingParenthesis = content.indexOf('(', annotationOffset);
|
int openingParenthesis = content.indexOf('(', annotationOffset);
|
||||||
int closingParenthesis = findAnnotationEnd(content, openingParenthesis);
|
int closingParenthesis = findAnnotationEnd(content, openingParenthesis);
|
||||||
@@ -103,7 +103,7 @@ public final class McpToolNameValidator {
|
|||||||
declarationsByName.computeIfAbsent(toolName, ignored -> new ArrayList<>())
|
declarationsByName.computeIfAbsent(toolName, ignored -> new ArrayList<>())
|
||||||
.add(new ToolDeclaration(moduleName, source, line));
|
.add(new ToolDeclaration(moduleName, source, line));
|
||||||
}
|
}
|
||||||
annotationOffset = content.indexOf("@McpFunction", closingParenthesis + 1);
|
annotationOffset = content.indexOf("@McpTool", closingParenthesis + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,26 +8,17 @@ import java.lang.reflect.Method;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.ai.mcp.annotation.McpTool;
|
||||||
|
|
||||||
class ToolSchemaResolverTest {
|
class ToolSchemaResolverTest {
|
||||||
|
|
||||||
private final ToolSchemaResolver resolver = new ToolSchemaResolver(new ObjectMapper());
|
private final ToolSchemaResolver resolver = new ToolSchemaResolver(new ObjectMapper());
|
||||||
|
|
||||||
@Test
|
|
||||||
void usesInlineSchemaBeforeAutomaticDtoSchema() throws Exception {
|
|
||||||
Method method = InlineSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class);
|
|
||||||
|
|
||||||
Map<String, Object> schema = resolver.resolve(method.getAnnotation(McpFunction.class), AutomaticRequest.class);
|
|
||||||
|
|
||||||
assertEquals(false, schema.get("additionalProperties"));
|
|
||||||
assertTrue(!properties(schema).containsKey("differentField"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void generatesSchemaFromRequestDtoWhenNoExplicitSchemaIsConfigured() throws Exception {
|
void generatesSchemaFromRequestDtoWhenNoExplicitSchemaIsConfigured() throws Exception {
|
||||||
Method method = AutomaticSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class);
|
Method method = AutomaticSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class);
|
||||||
|
|
||||||
Map<String, Object> schema = resolver.resolve(method.getAnnotation(McpFunction.class), AutomaticRequest.class);
|
Map<String, Object> schema = resolver.resolve(method.getAnnotation(McpTool.class), null, AutomaticRequest.class);
|
||||||
|
|
||||||
assertTrue(properties(schema).containsKey("differentField"));
|
assertTrue(properties(schema).containsKey("differentField"));
|
||||||
}
|
}
|
||||||
@@ -35,7 +26,7 @@ class ToolSchemaResolverTest {
|
|||||||
@Test
|
@Test
|
||||||
void resolvesExplicitOutputSchema() throws Exception {
|
void resolvesExplicitOutputSchema() throws Exception {
|
||||||
Method method = OutputSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class);
|
Method method = OutputSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class);
|
||||||
Map<String, Object> schema = resolver.resolveOutput(method.getAnnotation(McpFunction.class));
|
Map<String, Object> schema = resolver.resolveOutput(method.getAnnotation(McpTool.class));
|
||||||
assertEquals(false, schema.get("additionalProperties"));
|
assertEquals(false, schema.get("additionalProperties"));
|
||||||
assertTrue(properties(schema).containsKey("resultCode"));
|
assertTrue(properties(schema).containsKey("resultCode"));
|
||||||
}
|
}
|
||||||
@@ -45,7 +36,7 @@ class ToolSchemaResolverTest {
|
|||||||
Method method = AutomaticOutputSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class);
|
Method method = AutomaticOutputSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class);
|
||||||
|
|
||||||
Map<String, Object> schema = resolver.resolveOutput(
|
Map<String, Object> schema = resolver.resolveOutput(
|
||||||
method.getAnnotation(McpFunction.class), SimpleResponse.class);
|
method.getAnnotation(McpTool.class), SimpleResponse.class);
|
||||||
|
|
||||||
assertEquals(List.of("resultCode"), schema.get("required"));
|
assertEquals(List.of("resultCode"), schema.get("required"));
|
||||||
assertEquals(List.of("SUCCESS", "FAILURE"), property(schema, "resultCode").get("enum"));
|
assertEquals(List.of("SUCCESS", "FAILURE"), property(schema, "resultCode").get("enum"));
|
||||||
@@ -55,7 +46,7 @@ class ToolSchemaResolverTest {
|
|||||||
void doesNotEnableOutputValidationWhenOutputSchemaIsNotDeclared() throws Exception {
|
void doesNotEnableOutputValidationWhenOutputSchemaIsNotDeclared() throws Exception {
|
||||||
Method method = AutomaticSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class);
|
Method method = AutomaticSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class);
|
||||||
Map<String, Object> schema = resolver.resolveOutput(
|
Map<String, Object> schema = resolver.resolveOutput(
|
||||||
method.getAnnotation(McpFunction.class), AutomaticRequest.class);
|
method.getAnnotation(McpTool.class), AutomaticRequest.class);
|
||||||
assertTrue(schema.isEmpty());
|
assertTrue(schema.isEmpty());
|
||||||
}
|
}
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@@ -68,17 +59,14 @@ class ToolSchemaResolverTest {
|
|||||||
return (Map<String, Object>) properties(schema).get(name);
|
return (Map<String, Object>) properties(schema).get(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
static class InlineSchemaTool {
|
|
||||||
void search(AutomaticRequest request) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static class AutomaticSchemaTool {
|
static class AutomaticSchemaTool {
|
||||||
|
@McpTool(name = "oth.test.automatic.search")
|
||||||
void search(AutomaticRequest request) {
|
void search(AutomaticRequest request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static class AutomaticOutputSchemaTool {
|
static class AutomaticOutputSchemaTool {
|
||||||
|
@McpTool(name = "oth.test.output.search")
|
||||||
SimpleResponse search(AutomaticRequest request) {
|
SimpleResponse search(AutomaticRequest request) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -91,6 +79,7 @@ class ToolSchemaResolverTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static class OutputSchemaTool {
|
static class OutputSchemaTool {
|
||||||
|
@McpTool(name = "oth.test.explicit.search")
|
||||||
void search(AutomaticRequest request) {
|
void search(AutomaticRequest request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package io.shinhanlife.dap.lib.util;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
class ToolSourceUpdaterTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path temporaryRoot;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatesSdkAndProjectOwnedMetadataOnTheSameToolMethod() throws Exception {
|
||||||
|
Path source = temporaryRoot.resolve("dap-was-oth/src/main/java/example/SampleUseCase.java");
|
||||||
|
Files.createDirectories(source.getParent());
|
||||||
|
Files.writeString(source, """
|
||||||
|
package example;
|
||||||
|
import org.springframework.ai.mcp.annotation.McpTool;
|
||||||
|
import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||||
|
interface SampleUseCase {
|
||||||
|
@McpTool(name = "oth.cmm.sample.search", description = "old")
|
||||||
|
@ToolHint(register = false, requiresApproval = false)
|
||||||
|
void search();
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
ToolSourceUpdater.updateToolSource(temporaryRoot, "oth.cmm.sample.search", "customer", "new", true, true);
|
||||||
|
|
||||||
|
String updated = Files.readString(source);
|
||||||
|
assertTrue(updated.contains("@McpTool(name = \"oth.cmm.sample.search\", description = \"new\")"));
|
||||||
|
assertTrue(updated.contains("@ToolHint(register = true, requiresApproval = true"));
|
||||||
|
assertTrue(updated.contains("group = \"customer\""), updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ class McpToolNameValidatorTest {
|
|||||||
Path temporaryRoot;
|
Path temporaryRoot;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsDuplicateMcpFunctionNamesAcrossToolModules() throws IOException {
|
void rejectsDuplicateMcpToolNamesAcrossToolModules() throws IOException {
|
||||||
writeToolSource("dap-was-first", "FirstTool.java", "first", "oth.sms.notification.send");
|
writeToolSource("dap-was-first", "FirstTool.java", "first", "oth.sms.notification.send");
|
||||||
writeToolSource("dap-was-second", "SecondTool.java", "second", "oth.sms.notification.send");
|
writeToolSource("dap-was-second", "SecondTool.java", "second", "oth.sms.notification.send");
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ class McpToolNameValidatorTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void validationRunnerRejectsDuplicateMcpFunctionNamesBeforePackaging() throws IOException {
|
void validationRunnerRejectsDuplicateMcpToolNamesBeforePackaging() throws IOException {
|
||||||
writeToolSource("dap-was-first", "FirstTool.java", "first", "oth.sms.notification.send");
|
writeToolSource("dap-was-first", "FirstTool.java", "first", "oth.sms.notification.send");
|
||||||
writeToolSource("dap-was-second", "SecondTool.java", "second", "oth.sms.notification.send");
|
writeToolSource("dap-was-second", "SecondTool.java", "second", "oth.sms.notification.send");
|
||||||
|
|
||||||
@@ -90,10 +90,13 @@ class McpToolNameValidatorTest {
|
|||||||
Files.writeString(source, """
|
Files.writeString(source, """
|
||||||
package example;
|
package example;
|
||||||
|
|
||||||
|
import org.springframework.ai.mcp.annotation.McpTool;
|
||||||
|
|
||||||
class %s {
|
class %s {
|
||||||
|
@McpTool(name = "%s")
|
||||||
void execute() { }
|
void execute() { }
|
||||||
}
|
}
|
||||||
""".formatted(className, className, toolName));
|
""".formatted(className, toolName));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Path findProjectRoot() {
|
private Path findProjectRoot() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package io.shinhanlife.dap.mcc.manifest;
|
package io.shinhanlife.dap.lib.manifest;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package io.shinhanlife.dap.mcc.mcp;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
|
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
|
||||||
import io.shinhanlife.dap.lib.mcp.ToolMcpServerConfiguration;
|
import io.shinhanlife.dap.lib.mcp.ToolMcpServerConfiguration;
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import io.shinhanlife.dap.lib.util.JsonSchemaGenerator;
|
import io.shinhanlife.dap.lib.util.JsonSchemaGenerator;
|
||||||
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
|
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
|
||||||
|
import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||||
import io.shinhanlife.dap.mcc.biz.cmm.usecase.impl.ClaimSearchSchemaSampleUseCaseImpl;
|
import io.shinhanlife.dap.mcc.biz.cmm.usecase.impl.ClaimSearchSchemaSampleUseCaseImpl;
|
||||||
import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator;import io.shinhanlife.dap.mcc.biz.cmm.usecase.ClaimSearchSchemaSampleUseCase;
|
import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator;import io.shinhanlife.dap.mcc.biz.cmm.usecase.ClaimSearchSchemaSampleUseCase;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.ai.mcp.annotation.McpTool;
|
||||||
|
|
||||||
class ClaimSearchRequestSchemaTest {
|
class ClaimSearchRequestSchemaTest {
|
||||||
|
|
||||||
@@ -33,10 +35,11 @@ class ClaimSearchRequestSchemaTest {
|
|||||||
void resolvesSchemaFromToolModuleResource() throws Exception {
|
void resolvesSchemaFromToolModuleResource() throws Exception {
|
||||||
Method method = ClaimSearchSchemaSampleUseCase.class
|
Method method = ClaimSearchSchemaSampleUseCase.class
|
||||||
.getDeclaredMethod("search", ClaimSearchRequest.class);
|
.getDeclaredMethod("search", ClaimSearchRequest.class);
|
||||||
McpFunction function = method.getAnnotation(McpFunction.class);
|
McpTool function = method.getAnnotation(McpTool.class);
|
||||||
|
ToolHint hint = method.getAnnotation(ToolHint.class);
|
||||||
|
|
||||||
Map<String, Object> schema = new ToolSchemaResolver(new ObjectMapper())
|
Map<String, Object> schema = new ToolSchemaResolver(new ObjectMapper())
|
||||||
.resolve(function, ClaimSearchRequest.class);
|
.resolve(function, hint, ClaimSearchRequest.class);
|
||||||
|
|
||||||
assertEquals("https://json-schema.org/draft/2020-12/schema", schema.get("$schema"));
|
assertEquals("https://json-schema.org/draft/2020-12/schema", schema.get("$schema"));
|
||||||
assertTrue(schema.containsKey("anyOf"));
|
assertTrue(schema.containsKey("anyOf"));
|
||||||
@@ -47,8 +50,10 @@ class ClaimSearchRequestSchemaTest {
|
|||||||
void resolvesOutputSchemaFromToolModuleResource() throws Exception {
|
void resolvesOutputSchemaFromToolModuleResource() throws Exception {
|
||||||
Method method = ClaimSearchSchemaSampleUseCase.class
|
Method method = ClaimSearchSchemaSampleUseCase.class
|
||||||
.getDeclaredMethod("search", ClaimSearchRequest.class);
|
.getDeclaredMethod("search", ClaimSearchRequest.class);
|
||||||
McpFunction function = method.getAnnotation(McpFunction.class);
|
McpTool function = method.getAnnotation(McpTool.class);
|
||||||
Map<String, Object> schema = new ToolSchemaResolver(new ObjectMapper()).resolveOutput(function);
|
ToolHint hint = method.getAnnotation(ToolHint.class);
|
||||||
|
Map<String, Object> schema = new ToolSchemaResolver(new ObjectMapper())
|
||||||
|
.resolveOutput(function, ClaimSearchResponse.class, hint);
|
||||||
assertEquals("https://json-schema.org/draft/2020-12/schema", schema.get("$schema"));
|
assertEquals("https://json-schema.org/draft/2020-12/schema", schema.get("$schema"));
|
||||||
assertTrue(properties(schema).containsKey("resultCode"));
|
assertTrue(properties(schema).containsKey("resultCode"));
|
||||||
assertTrue(properties(schema).containsKey("statusLabel"));
|
assertTrue(properties(schema).containsKey("statusLabel"));
|
||||||
@@ -61,7 +66,8 @@ class ClaimSearchRequestSchemaTest {
|
|||||||
Method method = ClaimSearchSchemaSampleUseCase.class
|
Method method = ClaimSearchSchemaSampleUseCase.class
|
||||||
.getDeclaredMethod("search", ClaimSearchRequest.class);
|
.getDeclaredMethod("search", ClaimSearchRequest.class);
|
||||||
Map<String, Object> schema = new ToolSchemaResolver(new ObjectMapper())
|
Map<String, Object> schema = new ToolSchemaResolver(new ObjectMapper())
|
||||||
.resolveOutput(method.getAnnotation(McpFunction.class));
|
.resolveOutput(method.getAnnotation(McpTool.class), ClaimSearchResponse.class,
|
||||||
|
method.getAnnotation(ToolHint.class));
|
||||||
|
|
||||||
ClaimSearchResponse response = new ClaimSearchSchemaSampleUseCaseImpl().search(new ClaimSearchRequest());
|
ClaimSearchResponse response = new ClaimSearchSchemaSampleUseCaseImpl().search(new ClaimSearchRequest());
|
||||||
ToolArgumentSchemaValidator validator = new ToolArgumentSchemaValidator(new ObjectMapper());
|
ToolArgumentSchemaValidator validator = new ToolArgumentSchemaValidator(new ObjectMapper());
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# MCP SDK Validator and Source Updater Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Keep tool-name validation and source metadata updates functional after replacing the custom `@McpFunction` annotation with Spring AI's `@McpTool`.
|
||||||
|
|
||||||
|
**Architecture:** `McpToolNameValidator` will scan `@McpTool(name = "...")` declarations for naming and duplication rules. `ToolSourceUpdater` will update SDK-owned fields (`name`, `description`) on `@McpTool` and project-owned fields (`group`, `register`, `requiresApproval`) on adjacent `@ToolHint`, without trying to write unsupported fields to the SDK annotation.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 21, JUnit 5, Spring AI MCP annotations, Gradle.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Preserve the existing tool-name convention and duplicate-name build validation.
|
||||||
|
- Do not reintroduce the removed custom `McpFunction` annotation.
|
||||||
|
- Keep project-specific metadata in `io.shinhanlife.dap.lib.annotation.ToolHint`.
|
||||||
|
- Verify with focused tests and `gradlew.bat test`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Migrate tool-name validation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/validation/McpToolNameValidator.java`
|
||||||
|
- Modify: `dap-was-lib/src/test/java/io/shinhanlife/dap/lib/validation/McpToolNameValidatorTest.java`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Java source files containing `@McpTool(name = "...")`.
|
||||||
|
- Produces: `McpToolNameValidator.assertUnique(Path)` that rejects duplicate or invalid SDK tool names.
|
||||||
|
|
||||||
|
- [ ] Write tests using `@McpTool` source snippets for duplicate, invalid, and valid names.
|
||||||
|
- [ ] Run `:dap-was-lib:test --tests *McpToolNameValidatorTest` and confirm current implementation does not detect those annotations.
|
||||||
|
- [ ] Replace the annotation scan target from `@McpFunction` to `@McpTool`.
|
||||||
|
- [ ] Re-run the focused test and confirm it passes.
|
||||||
|
|
||||||
|
### Task 2: Migrate source metadata update behavior
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/ToolSourceUpdater.java`
|
||||||
|
- Create: `dap-was-lib/src/test/java/io/shinhanlife/dap/lib/util/ToolSourceUpdaterTest.java`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: a tool interface source containing `@McpTool` and `@ToolHint`.
|
||||||
|
- Produces: `ToolSourceUpdater.updateToolSource(String, String, String, boolean, Boolean)` that updates SDK name/description and project metadata without emitting invalid `@McpTool` attributes.
|
||||||
|
|
||||||
|
- [ ] Write a temporary-source test defining one SDK annotation and one `ToolHint` annotation.
|
||||||
|
- [ ] Run the focused test and confirm the legacy updater cannot locate `@McpTool` declarations.
|
||||||
|
- [ ] Update annotation matching and replacements: `description` belongs to `@McpTool`; `group`, `register`, and `requiresApproval` belong to `@ToolHint`.
|
||||||
|
- [ ] Re-run the focused test and confirm it passes.
|
||||||
|
|
||||||
|
### Task 3: Restore test-suite compilation after SDK migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: stale tests referencing `McpFunction` or old package locations.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `McpTool`, `ToolHint`, current `ToolSchemaResolver`, and current package names.
|
||||||
|
- Produces: compilation and behavioral coverage aligned with the SDK-based production code.
|
||||||
|
|
||||||
|
- [ ] Replace stale annotation and resolver signatures in tests.
|
||||||
|
- [ ] Fix package relocation and missing assertion imports without widening production visibility.
|
||||||
|
- [ ] Run `gradlew.bat test`.
|
||||||
Reference in New Issue
Block a user