feat: publish tool manifest endpoint
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m50s
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m50s
This commit is contained in:
@@ -63,5 +63,14 @@ public @interface McpFunction {
|
||||
boolean idempotentHint() default false;
|
||||
boolean openWorldHint() default false;
|
||||
|
||||
/** Version exposed as _meta.version in the Tool Manifest. */
|
||||
String version() default "1.0.0";
|
||||
|
||||
/** Maximum execution time exposed as _meta.timeoutMillis in the Tool Manifest. */
|
||||
long timeoutMillis() default 300000L;
|
||||
|
||||
/** Whether the Tool is available for MCP exposure. */
|
||||
boolean enabled() default true;
|
||||
|
||||
// 추가: 툴 별 기본 Timeout 설정 (기본 300초 = 300000ms)
|
||||
}
|
||||
|
||||
@@ -26,5 +26,12 @@ import java.util.Map;
|
||||
public class McpProperties {
|
||||
|
||||
private String namespace;
|
||||
private Manifest manifest = new Manifest();
|
||||
|
||||
@Data
|
||||
public static class Manifest {
|
||||
private String bundleId;
|
||||
private String namePrefix;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -166,6 +166,8 @@ public class ToolScaffolder {
|
||||
package %s.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.shinhanlife.dap.lib.annotation.McpOutputSchema;
|
||||
import io.shinhanlife.dap.lib.annotation.McpValidation;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
@@ -183,16 +185,22 @@ public class ToolScaffolder {
|
||||
* </pre>
|
||||
*/
|
||||
@Data
|
||||
@McpOutputSchema
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class %sResponse {
|
||||
private String status;
|
||||
private String message;
|
||||
// TODO: Add response fields here
|
||||
@McpValidation(required = true)
|
||||
private String resultCode;
|
||||
|
||||
@McpValidation(maxLength = 200, nullable = true)
|
||||
private String resultMessage;
|
||||
|
||||
// TODO: Add response fields here. Do not include PII in the Tool response.
|
||||
}
|
||||
""".formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName);
|
||||
Files.writeString(dtoDir.resolve(baseName + "Response.java"), resContent);
|
||||
|
||||
String toolName = baseName.isEmpty() ? baseName : Character.toLowerCase(baseName.charAt(0)) + baseName.substring(1);
|
||||
String rawToolName = baseName.isEmpty() ? baseName : Character.toLowerCase(baseName.charAt(0)) + baseName.substring(1);
|
||||
String toolName = group.toLowerCase() + "." + rawToolName;
|
||||
|
||||
String serviceInterfaceContent = """
|
||||
package %s.usecase;
|
||||
@@ -214,7 +222,10 @@ public class ToolScaffolder {
|
||||
mappingId = "%s",
|
||||
register = %s,
|
||||
requiresApproval = false,
|
||||
openWorldHint = true
|
||||
openWorldHint = true,
|
||||
version = "1.0.0",
|
||||
timeoutMillis = 300000L,
|
||||
enabled = true
|
||||
)
|
||||
Object execute(%sRequest req);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ public class ToolMetadata {
|
||||
// 1. Tool 기본 정보
|
||||
private String uid; // UUID 형식의 고유 식별자
|
||||
private String semver; // 버전 (예: 1.0.0)
|
||||
private Long timeoutMillis;
|
||||
private Boolean enabled;
|
||||
private String displayName; // 사람이 읽는 라벨 (1-128자)
|
||||
private String name; // MCP 서브툴 명칭 (64자 이하, 예: CustomerSearchTool)
|
||||
private String description; // 툴의 목적 및 설명 (LLM 프롬프트에 활용 가능)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package io.shinhanlife.dap.mcc.manifest;
|
||||
|
||||
/** Behaviour hints exposed by the Tool Service manifest. */
|
||||
public record ToolManifestAnnotations(
|
||||
String title,
|
||||
boolean readOnlyHint,
|
||||
boolean destructiveHint,
|
||||
boolean idempotentHint,
|
||||
boolean openWorldHint) {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package io.shinhanlife.dap.mcc.manifest;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.Map;
|
||||
|
||||
/** One MCP Tool declaration published by a Tool Service. */
|
||||
public record ToolManifestItem(
|
||||
String name,
|
||||
String endpoint,
|
||||
String title,
|
||||
String description,
|
||||
Map<String, Object> inputSchema,
|
||||
ToolManifestAnnotations annotations,
|
||||
@JsonProperty("_meta") ToolManifestMeta meta) {
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package io.shinhanlife.dap.mcc.manifest;
|
||||
|
||||
/** Operational metadata exposed by the Tool Service manifest. */
|
||||
public record ToolManifestMeta(String version, long timeoutMillis, boolean enabled) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package io.shinhanlife.dap.mcc.manifest;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Top-level response for GET /tool-manifest. */
|
||||
public record ToolManifestResponse(String bundleId, String revision, List<ToolManifestItem> tools) {
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package io.shinhanlife.dap.mcc.manifest;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.shinhanlife.dap.lib.config.McpProperties;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.mcc.usecase.ToolRegistryHeartbeatSender;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Supplier;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Builds the Tool Service owned manifest consumed by the MCP server. */
|
||||
@Service
|
||||
public class ToolManifestService {
|
||||
|
||||
private static final long DEFAULT_TIMEOUT_MILLIS = 300000L;
|
||||
private final Supplier<List<ToolMetadata>> toolSupplier;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final McpProperties properties;
|
||||
private String lastFingerprint;
|
||||
private String lastRevision;
|
||||
|
||||
@Autowired
|
||||
public ToolManifestService(ToolRegistryHeartbeatSender heartbeatSender, ObjectMapper objectMapper,
|
||||
McpProperties properties) {
|
||||
this(heartbeatSender::getAllScannedTools, objectMapper, properties);
|
||||
}
|
||||
|
||||
ToolManifestService(Supplier<List<ToolMetadata>> toolSupplier, ObjectMapper objectMapper,
|
||||
McpProperties properties) {
|
||||
this.toolSupplier = toolSupplier;
|
||||
this.objectMapper = objectMapper;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
public ToolManifestResponse currentManifest() {
|
||||
String bundleId = properties.getManifest() == null ? null : properties.getManifest().getBundleId();
|
||||
if (bundleId == null || bundleId.isBlank()) {
|
||||
throw new IllegalStateException("mcp.manifest.bundle-id must be configured");
|
||||
}
|
||||
|
||||
List<ToolManifestItem> tools = toolSupplier.get().stream()
|
||||
.map(this::toManifestItem)
|
||||
.sorted(Comparator.comparing(ToolManifestItem::name))
|
||||
.toList();
|
||||
validate(tools);
|
||||
return new ToolManifestResponse(bundleId, revision(bundleId, tools), tools);
|
||||
}
|
||||
|
||||
private ToolManifestItem toManifestItem(ToolMetadata tool) {
|
||||
String title = tool.getDisplayName() == null || tool.getDisplayName().isBlank()
|
||||
? tool.getName() : tool.getDisplayName();
|
||||
Map<String, Object> schema = tool.getParametersSchema() == null ? emptySchema() : tool.getParametersSchema();
|
||||
return new ToolManifestItem(
|
||||
tool.getName(), endpoint(tool), title, tool.getDescription(), schema,
|
||||
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(),
|
||||
tool.getEnabled() == null || tool.getEnabled()));
|
||||
}
|
||||
|
||||
private void validate(List<ToolManifestItem> tools) {
|
||||
String namePrefix = properties.getManifest() == null ? null : properties.getManifest().getNamePrefix();
|
||||
Set<String> names = new LinkedHashSet<>();
|
||||
for (ToolManifestItem tool : tools) {
|
||||
if (tool.name() == null || tool.name().isBlank()) {
|
||||
throw new IllegalStateException("Tool manifest contains a blank tool name");
|
||||
}
|
||||
if (!names.add(tool.name())) {
|
||||
throw new IllegalStateException("Tool manifest contains duplicate tool name: " + tool.name());
|
||||
}
|
||||
if (namePrefix != null && !namePrefix.isBlank() && !tool.name().startsWith(namePrefix)) {
|
||||
throw new IllegalStateException("Tool name does not match mcp.manifest.name-prefix: " + tool.name());
|
||||
}
|
||||
if (!"object".equals(tool.inputSchema().get("type"))) {
|
||||
throw new IllegalStateException("Tool inputSchema root type must be object: " + tool.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized String revision(String bundleId, List<ToolManifestItem> tools) {
|
||||
String fingerprint = fingerprint(bundleId, tools);
|
||||
if (!fingerprint.equals(lastFingerprint)) {
|
||||
long nextTimestamp = System.currentTimeMillis();
|
||||
if (lastRevision != null) {
|
||||
nextTimestamp = Math.max(nextTimestamp, Long.parseLong(lastRevision) + 1);
|
||||
}
|
||||
lastFingerprint = fingerprint;
|
||||
lastRevision = Long.toString(nextTimestamp);
|
||||
}
|
||||
return lastRevision;
|
||||
}
|
||||
|
||||
private String fingerprint(String bundleId, List<ToolManifestItem> tools) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(Map.of("bundleId", bundleId, "tools", tools));
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("Failed to build Tool manifest revision source", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String endpoint(ToolMetadata tool) {
|
||||
if (tool.getEndpoint() != null && !tool.getEndpoint().isBlank()) {
|
||||
return tool.getEndpoint();
|
||||
}
|
||||
if (tool.getPodUrl() == null || tool.getPodUrl().isBlank()) {
|
||||
return "/mcp/" + tool.getName();
|
||||
}
|
||||
return tool.getPodUrl().replaceAll("/+$", "") + "/mcp/" + tool.getName();
|
||||
}
|
||||
private Map<String, Object> emptySchema() {
|
||||
return Map.of("type", "object", "properties", Map.of(), "additionalProperties", false);
|
||||
}
|
||||
|
||||
private boolean isTrue(Boolean value) {
|
||||
return Boolean.TRUE.equals(value);
|
||||
}
|
||||
|
||||
private String defaultString(String value, String fallback) {
|
||||
return value == null || value.isBlank() ? fallback : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package io.shinhanlife.dap.mcc.presentation;
|
||||
|
||||
import io.shinhanlife.dap.mcc.manifest.ToolManifestResponse;
|
||||
import io.shinhanlife.dap.mcc.manifest.ToolManifestService;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Read-only Tool Service manifest endpoint for MCP background discovery. */
|
||||
@RestController
|
||||
public class ToolManifestController {
|
||||
|
||||
private final ToolManifestService toolManifestService;
|
||||
|
||||
public ToolManifestController(ToolManifestService toolManifestService) {
|
||||
this.toolManifestService = toolManifestService;
|
||||
}
|
||||
|
||||
@GetMapping(value = "/tool-manifest", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<ToolManifestResponse> getManifest(
|
||||
@RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false) String ifNoneMatch) {
|
||||
ToolManifestResponse manifest = toolManifestService.currentManifest();
|
||||
String eTag = '"' + manifest.revision() + '"';
|
||||
if (eTag.equals(ifNoneMatch)) {
|
||||
return ResponseEntity.status(304).eTag(eTag).build();
|
||||
}
|
||||
return ResponseEntity.ok().eTag(eTag).body(manifest);
|
||||
}
|
||||
}
|
||||
@@ -104,12 +104,15 @@ public class ToolRegistryHeartbeatSender {
|
||||
meta.setUid(UUID.nameUUIDFromBytes(subToolName.getBytes()).toString());
|
||||
meta.setDisplayName(baseName);
|
||||
meta.setName(subToolName);
|
||||
meta.setSemver("1.0.0");
|
||||
meta.setSemver(functionAnnotation.version());
|
||||
meta.setTimeoutMillis(functionAnnotation.timeoutMillis());
|
||||
meta.setEnabled(functionAnnotation.enabled());
|
||||
meta.setDescription(functionAnnotation.description());
|
||||
meta.setCategoryKey(toolAnnotation.categoryKey());
|
||||
meta.setIntegrationType(toolAnnotation.routingType());
|
||||
meta.setMciServiceId(functionAnnotation.mappingId());
|
||||
meta.setPodUrl(podUrl);
|
||||
meta.setEndpoint(podUrl.replaceAll("/+$", "") + "/mcp/" + subToolName);
|
||||
|
||||
boolean isVisible = functionAnnotation.visible();
|
||||
meta.setVisible(isVisible);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
|
||||
class ToolScaffolderTest {
|
||||
|
||||
@Test
|
||||
void generatesManifestReadyToolAndOutputSchemaDto() throws Exception {
|
||||
String moduleName = "build/scaffold-manifest-test";
|
||||
|
||||
ToolScaffolder.scaffold("claim search", "CLM0001", "청구 조회", "cmm", "HTTP", moduleName,
|
||||
"tester", "2026.08.04", true, null);
|
||||
|
||||
Path root = Path.of(moduleName, "src/main/java/io/shinhanlife/dap/mcc/biz/cmm");
|
||||
String useCase = Files.readString(root.resolve("usecase/ClaimSearchUseCase.java"));
|
||||
String response = Files.readString(root.resolve("dto/ClaimSearchResponse.java"));
|
||||
|
||||
assertTrue(useCase.contains("name = \"cmm.claimSearch\""));
|
||||
assertTrue(useCase.contains("version = \"1.0.0\""));
|
||||
assertTrue(useCase.contains("timeoutMillis = 300000L"));
|
||||
assertTrue(response.contains("@McpOutputSchema"));
|
||||
assertTrue(response.contains("@McpValidation"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package io.shinhanlife.dap.mcc.manifest;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.shinhanlife.dap.lib.config.McpProperties;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ToolManifestServiceTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void buildsStandardManifestAndDerivesRevisionFromToolDefinition() {
|
||||
McpProperties properties = manifestProperties("insurance-processing", "processing.");
|
||||
ToolManifestService service = new ToolManifestService(
|
||||
() -> List.of(tool("processing.contract.inquiry", "1.2.0", 3000)), objectMapper, properties);
|
||||
|
||||
ToolManifestResponse manifest = service.currentManifest();
|
||||
|
||||
assertEquals("insurance-processing", manifest.bundleId());
|
||||
assertTrue(manifest.revision().matches("\\d+"));
|
||||
assertEquals(1, manifest.tools().size());
|
||||
ToolManifestItem item = manifest.tools().getFirst();
|
||||
assertEquals("processing.contract.inquiry", item.name());
|
||||
assertEquals("http://tool-processing.ax-hub.svc.cluster.local:8080/mcp/processing.contract.inquiry",
|
||||
item.endpoint());
|
||||
assertEquals("계약 조회", item.title());
|
||||
assertEquals("object", item.inputSchema().get("type"));
|
||||
assertTrue(item.annotations().readOnlyHint());
|
||||
assertEquals("1.2.0", item.meta().version());
|
||||
assertEquals(3000, item.meta().timeoutMillis());
|
||||
}
|
||||
|
||||
@Test
|
||||
void changesRevisionWhenToolDefinitionChanges() {
|
||||
McpProperties properties = manifestProperties("insurance-processing", "processing.");
|
||||
ToolManifestService before = new ToolManifestService(
|
||||
() -> List.of(tool("processing.contract.inquiry", "1.2.0", 3000)), objectMapper, properties);
|
||||
ToolManifestService after = new ToolManifestService(
|
||||
() -> List.of(tool("processing.contract.inquiry", "1.2.0", 5000)), objectMapper, properties);
|
||||
|
||||
assertTrue(!before.currentManifest().revision().equals(after.currentManifest().revision()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEntireManifestWhenToolNameDoesNotMatchConfiguredPrefix() {
|
||||
McpProperties properties = manifestProperties("insurance-processing", "processing.");
|
||||
ToolManifestService service = new ToolManifestService(
|
||||
() -> List.of(tool("notification.sms.send", "1.0.0", 3000)), objectMapper, properties);
|
||||
|
||||
IllegalStateException error = assertThrows(IllegalStateException.class, service::currentManifest);
|
||||
|
||||
assertTrue(error.getMessage().contains("name-prefix"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEntireManifestWhenToolNamesAreDuplicated() {
|
||||
McpProperties properties = manifestProperties("insurance-processing", "processing.");
|
||||
ToolManifestService service = new ToolManifestService(
|
||||
() -> List.of(tool("processing.contract.inquiry", "1.0.0", 3000),
|
||||
tool("processing.contract.inquiry", "1.0.1", 3000)), objectMapper, properties);
|
||||
|
||||
IllegalStateException error = assertThrows(IllegalStateException.class, service::currentManifest);
|
||||
|
||||
assertTrue(error.getMessage().contains("duplicate"));
|
||||
}
|
||||
|
||||
private McpProperties manifestProperties(String bundleId, String namePrefix) {
|
||||
McpProperties properties = new McpProperties();
|
||||
McpProperties.Manifest manifest = new McpProperties.Manifest();
|
||||
manifest.setBundleId(bundleId);
|
||||
manifest.setNamePrefix(namePrefix);
|
||||
properties.setManifest(manifest);
|
||||
return properties;
|
||||
}
|
||||
|
||||
private ToolMetadata tool(String name, String version, long timeoutMillis) {
|
||||
return ToolMetadata.builder()
|
||||
.name(name)
|
||||
.podUrl("http://tool-processing.ax-hub.svc.cluster.local:8080")
|
||||
.displayName("계약 조회")
|
||||
.description("계약번호로 계약 정보를 조회합니다.")
|
||||
.parametersSchema(Map.of("type", "object", "properties", Map.of("contractNo", Map.of("type", "string")),
|
||||
"required", List.of("contractNo"), "additionalProperties", false))
|
||||
.semver(version)
|
||||
.timeoutMillis(timeoutMillis)
|
||||
.enabled(true)
|
||||
.readOnlyHint(true)
|
||||
.destructiveHint(false)
|
||||
.idempotentHint(true)
|
||||
.openWorldHint(false)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user