forked from kimhyungsik/ax_hub_mcp_tool
feat: expose tool timeout and retry metadata
All checks were successful
Deploy Tools / deploy (push) Successful in 1m27s
All checks were successful
Deploy Tools / deploy (push) Successful in 1m27s
This commit is contained in:
@@ -29,6 +29,9 @@ public @interface GrowToolHint {
|
||||
String mappingId() default "";
|
||||
String inputSchemaResource() default "";
|
||||
String outputSchemaResource() default "";
|
||||
long timeoutMillis() default 5000L;
|
||||
boolean retryEnabled() default true;
|
||||
int retryMaxAttempts() default 3;
|
||||
|
||||
// Meta 정보 추가 (보고용 샘플)
|
||||
String displayDescription() default "";
|
||||
|
||||
@@ -7,6 +7,8 @@ import java.util.List;
|
||||
public record ToolManifestMeta(
|
||||
String version,
|
||||
long timeoutMillis,
|
||||
boolean retryEnabled,
|
||||
int retryMaxAttempts,
|
||||
boolean enabled,
|
||||
List<String> exampleQueries,
|
||||
List<String> tags,
|
||||
|
||||
@@ -21,7 +21,9 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class ToolManifestService {
|
||||
|
||||
private static final long DEFAULT_TIMEOUT_MILLIS = 300000L;
|
||||
private static final long DEFAULT_TIMEOUT_MILLIS = 5000L;
|
||||
private static final boolean DEFAULT_RETRY_ENABLED = true;
|
||||
private static final int DEFAULT_RETRY_MAX_ATTEMPTS = 3;
|
||||
private static final AtomicLong LAST_ISSUED_REVISION = new AtomicLong();
|
||||
private final Supplier<List<ToolMetadata>> toolSupplier;
|
||||
private final ObjectMapper objectMapper;
|
||||
@@ -88,7 +90,9 @@ public class ToolManifestService {
|
||||
new ToolManifestAnnotations(title, isTrue(tool.getReadOnlyHint()), isTrue(tool.getDestructiveHint()),
|
||||
isTrue(tool.getIdempotentHint()), isTrue(tool.getOpenWorldHint())),
|
||||
new ToolManifestMeta(defaultString(tool.getSemver(), "1.0.0"),
|
||||
tool.getTimeoutMillis() == null ? DEFAULT_TIMEOUT_MILLIS : tool.getTimeoutMillis(),
|
||||
positiveOrDefault(tool.getTimeoutMillis(), DEFAULT_TIMEOUT_MILLIS),
|
||||
tool.getRetryEnabled() == null ? DEFAULT_RETRY_ENABLED : tool.getRetryEnabled(),
|
||||
positiveOrDefault(tool.getRetryMaxAttempts(), DEFAULT_RETRY_MAX_ATTEMPTS),
|
||||
tool.getEnabled() == null || tool.getEnabled(),
|
||||
defaultList(tool.getExampleQueries()), defaultList(tool.getTags()),
|
||||
tool.getMciServiceId(), defaultList(tool.getRequiredEnvKeys()), tool.getOwnerOrg(),
|
||||
@@ -126,6 +130,14 @@ public class ToolManifestService {
|
||||
return lastRevision;
|
||||
}
|
||||
|
||||
private long positiveOrDefault(Long value, long defaultValue) {
|
||||
return value != null && value > 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
private int positiveOrDefault(Integer value, int defaultValue) {
|
||||
return value != null && value > 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
private String fingerprint(String bundleId, List<ToolManifestItem> tools) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(Map.of("bundleId", bundleId, "tools", tools));
|
||||
|
||||
@@ -40,9 +40,20 @@ public final class ToolMetadataMcpMapper {
|
||||
put(meta, "legacy_interface_id", metadata.getMciServiceId());
|
||||
put(meta, "required_env_keys", metadata.getRequiredEnvKeys());
|
||||
put(meta, "owner_org", metadata.getOwnerOrg());
|
||||
meta.put("timeoutMillis", positiveOrDefault(metadata.getTimeoutMillis(), 5000L));
|
||||
meta.put("retryEnabled", metadata.getRetryEnabled() == null || metadata.getRetryEnabled());
|
||||
meta.put("retryMaxAttempts", positiveOrDefault(metadata.getRetryMaxAttempts(), 3));
|
||||
return Map.copyOf(meta);
|
||||
}
|
||||
|
||||
private static long positiveOrDefault(Long value, long defaultValue) {
|
||||
return value != null && value > 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
private static int positiveOrDefault(Integer value, int defaultValue) {
|
||||
return value != null && value > 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
private static void put(Map<String, Object> target, String key, Object value) {
|
||||
if (value instanceof String text && !text.isBlank()) {
|
||||
target.put(key, text);
|
||||
|
||||
@@ -130,7 +130,9 @@ public class ToolRegistryHeartbeatSender {
|
||||
meta.setDisplayName(displayName);
|
||||
meta.setName(subToolName);
|
||||
meta.setSemver("1.0.0");
|
||||
meta.setTimeoutMillis(5000L);
|
||||
meta.setTimeoutMillis(hintAnnotation == null ? 5000L : positiveOrDefault(hintAnnotation.timeoutMillis(), 5000L));
|
||||
meta.setRetryEnabled(hintAnnotation == null || hintAnnotation.retryEnabled());
|
||||
meta.setRetryMaxAttempts(hintAnnotation == null ? 3 : positiveOrDefault(hintAnnotation.retryMaxAttempts(), 3));
|
||||
meta.setEnabled(true);
|
||||
meta.setDescription(functionAnnotation.description());
|
||||
|
||||
@@ -217,6 +219,14 @@ public class ToolRegistryHeartbeatSender {
|
||||
log.info(" [ToolScanner] 총 {}개 Tool 메타데이터 생성 완료", allScannedTools.size());
|
||||
}
|
||||
|
||||
private long positiveOrDefault(long value, long defaultValue) {
|
||||
return value > 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
private int positiveOrDefault(int value, int defaultValue) {
|
||||
return value > 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @McpTool / @GrowToolHint 검색
|
||||
* <p>
|
||||
|
||||
@@ -231,7 +231,10 @@ public class ToolScaffolder {
|
||||
.append("\", description = \"").append(javaText(option(tool.description(), ""))).append("\")\n")
|
||||
.append(" @GrowToolHint(\n")
|
||||
.append(" requiresApproval = ").append(isMutation).append(",\n")
|
||||
.append(" categoryKey = \"").append(tool.group().toLowerCase(Locale.ROOT)).append("\",\n");
|
||||
.append(" categoryKey = \"").append(tool.group().toLowerCase(Locale.ROOT)).append("\",\n")
|
||||
.append(" timeoutMillis = 5000L,\n")
|
||||
.append(" retryEnabled = true,\n")
|
||||
.append(" retryMaxAttempts = 3,\n");
|
||||
if (tool.interfaceId() != null && !tool.interfaceId().isBlank()) {
|
||||
methods.append(" mappingId = \"").append(javaText(tool.interfaceId())).append("\",\n");
|
||||
}
|
||||
@@ -383,7 +386,10 @@ public class ToolScaffolder {
|
||||
.append("\", description = \"").append(javaText(option(tool.description(), ""))).append("\")\n")
|
||||
.append(" @GrowToolHint(\n")
|
||||
.append(" requiresApproval = ").append(isMutation).append(",\n")
|
||||
.append(" categoryKey = \"").append(tool.group().toLowerCase(Locale.ROOT)).append("\",\n");
|
||||
.append(" categoryKey = \"").append(tool.group().toLowerCase(Locale.ROOT)).append("\",\n")
|
||||
.append(" timeoutMillis = 5000L,\n")
|
||||
.append(" retryEnabled = true,\n")
|
||||
.append(" retryMaxAttempts = 3,\n");
|
||||
String mappingId = option(tool.interfaceId(), tool.httpApiName());
|
||||
if (mappingId != null && !mappingId.isBlank()) {
|
||||
declBuilder.append(" mappingId = \"").append(javaText(mappingId)).append("\",\n");
|
||||
@@ -550,7 +556,7 @@ public class ToolScaffolder {
|
||||
String schemaResourceDirectory = "classpath:tool-schemas/" + group.toLowerCase() + "/";
|
||||
String inputSchemaResource = useSchemaResource ? schemaResourceDirectory + toKebabCase(baseName) + "-resource-input-schema.json" : null;
|
||||
String outputSchemaResource = useSchemaResource ? schemaResourceDirectory + toKebabCase(baseName) + "-resource-output-schema.json" : null;
|
||||
String defaultWorkspace = "c:\\eGovFrameDev-4.3.1-64bit\\workspace-egov\\dat-was-datmt";
|
||||
String defaultWorkspace = "c:\\eGovFrameDev-4.3.1-64bit\\workspace-egov\\dat-was-dasmt";
|
||||
String workspace = getOrAsk(args, 10, scanner, "11. 대상 프로젝트 워크스페이스 경로 (default: " + defaultWorkspace + "): ");
|
||||
if (workspace.trim().isEmpty()) {
|
||||
workspace = defaultWorkspace;
|
||||
@@ -766,6 +772,9 @@ public class ToolScaffolder {
|
||||
}
|
||||
sb.append(" requiresApproval = ").append(isMutation).append(",\n");
|
||||
sb.append(" categoryKey = \"").append(group.toLowerCase(Locale.ROOT)).append("\",\n");
|
||||
sb.append(" timeoutMillis = 5000L,\n");
|
||||
sb.append(" retryEnabled = true,\n");
|
||||
sb.append(" retryMaxAttempts = 3,\n");
|
||||
if (interfaceId != null && !interfaceId.isBlank()) {
|
||||
sb.append(" mappingId = \"").append(interfaceId).append("\",\n");
|
||||
}
|
||||
|
||||
@@ -123,6 +123,8 @@ public class ToolMetadata {
|
||||
|
||||
@Builder.Default
|
||||
private Boolean retryEnabled = true;
|
||||
@Builder.Default
|
||||
private Integer retryMaxAttempts = 3;
|
||||
|
||||
@Builder.Default
|
||||
private Integer circuitBreakerFailureThreshold = 0;
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
'X-Request-Id': request,
|
||||
'X-Request-Time': new Date().toISOString(),
|
||||
'X-Vrtl-Praf-No': 'V100001',
|
||||
'X-App-Code': 'DATMT',
|
||||
'X-App-Code': 'DASMT',
|
||||
'X-Project-Code': 'AXHUB',
|
||||
'X-User-Ip': '127.0.0.1',
|
||||
'X-Caller-Ip': '127.0.0.1',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package io.shinhanlife.dat.lib.annotation;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrowToolHintTest {
|
||||
|
||||
@Test
|
||||
void exposesResilienceDefaults() throws Exception {
|
||||
assertEquals(5000L, GrowToolHint.class.getMethod("timeoutMillis").getDefaultValue());
|
||||
assertEquals(true, GrowToolHint.class.getMethod("retryEnabled").getDefaultValue());
|
||||
assertEquals(3, GrowToolHint.class.getMethod("retryMaxAttempts").getDefaultValue());
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ class ToolManifestServiceTest {
|
||||
assertTrue(item.annotations().readOnlyHint());
|
||||
assertEquals("1.2.0", item.meta().version());
|
||||
assertEquals(3000, item.meta().timeoutMillis());
|
||||
assertEquals(false, item.meta().retryEnabled());
|
||||
assertEquals(5, item.meta().retryMaxAttempts());
|
||||
assertEquals(List.of("계약 상태를 알려줘", "내 계약을 조회해줘", "계약번호로 찾아줘"),
|
||||
item.meta().exampleQueries());
|
||||
}
|
||||
@@ -95,6 +97,8 @@ class ToolManifestServiceTest {
|
||||
"required", List.of("contractNo"), "additionalProperties", false))
|
||||
.semver(version)
|
||||
.timeoutMillis(timeoutMillis)
|
||||
.retryEnabled(false)
|
||||
.retryMaxAttempts(5)
|
||||
.enabled(true)
|
||||
.readOnlyHint(true)
|
||||
.destructiveHint(false)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package io.shinhanlife.dat.lib.mcp;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import io.shinhanlife.dat.mcc.dto.ToolMetadata;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ToolMetadataMcpMapperTest {
|
||||
|
||||
@Test
|
||||
void exposesResilienceSettingsInMcpToolMeta() {
|
||||
ToolMetadata metadata = ToolMetadata.builder()
|
||||
.timeoutMillis(12000L)
|
||||
.retryEnabled(false)
|
||||
.retryMaxAttempts(5)
|
||||
.build();
|
||||
|
||||
Map<String, Object> meta = ToolMetadataMcpMapper.meta(metadata);
|
||||
|
||||
assertEquals(12000L, meta.get("timeoutMillis"));
|
||||
assertEquals(false, meta.get("retryEnabled"));
|
||||
assertEquals(5, meta.get("retryMaxAttempts"));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import io.shinhanlife.dat.lib.annotation.GrowToolHint;
|
||||
import io.shinhanlife.dat.lib.config.McpProperties;
|
||||
import io.shinhanlife.dat.lib.util.ToolSchemaResolver;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
@@ -48,6 +49,18 @@ class ToolRegistryHeartbeatSenderTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsResilienceValuesOnlyFromGrowToolHint() throws Exception {
|
||||
ToolRegistryHeartbeatSender sender = senderWithOneTool("http://127.0.0.1:1");
|
||||
|
||||
sender.init();
|
||||
|
||||
var metadata = sender.getAllScannedTools().getFirst();
|
||||
assertThat(metadata.getTimeoutMillis()).isEqualTo(12000L);
|
||||
assertThat(metadata.getRetryEnabled()).isFalse();
|
||||
assertThat(metadata.getRetryMaxAttempts()).isEqualTo(1);
|
||||
}
|
||||
|
||||
private ToolRegistryHeartbeatSender senderWithOneTool(String gatewayUrl) throws Exception {
|
||||
ApplicationContext applicationContext = mock(ApplicationContext.class);
|
||||
when(applicationContext.getBeansOfType(Object.class)).thenReturn(Map.of("tool", new SampleTool()));
|
||||
@@ -87,6 +100,7 @@ class ToolRegistryHeartbeatSenderTest {
|
||||
|
||||
static class SampleTool {
|
||||
@McpTool(name = "test_sample_tool")
|
||||
@GrowToolHint(timeoutMillis = 12000L, retryEnabled = false, retryMaxAttempts = 1)
|
||||
void execute() {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,9 @@ class ToolScaffolderTest {
|
||||
String useCase = Files.readString(useCasePath);
|
||||
|
||||
assertTrue(useCase.contains("name = \"smp_employee_search\""), useCase);
|
||||
assertTrue(useCase.contains("timeoutMillis = 5000L"), useCase);
|
||||
assertTrue(useCase.contains("retryEnabled = true"), useCase);
|
||||
assertTrue(useCase.contains("retryMaxAttempts = 3"), useCase);
|
||||
assertTrue(useCase.contains("whenToUse = \"사용자가 이 업무 기능의 실행 또는 조회를 요청할 때 사용합니다.\""), useCase);
|
||||
assertTrue(useCase.contains("exampleQueries = {\"직원 조회 정보를 보여줘\""), useCase);
|
||||
assertTrue(useCase.contains("ownerOrg = \"MCP_TOOL\""), useCase);
|
||||
|
||||
Reference in New Issue
Block a user