feat: publish tool manifest endpoint
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m50s

This commit is contained in:
jade
2026-08-04 15:58:59 +09:00
parent 08ccc043da
commit a58af1606e
16 changed files with 477 additions and 7 deletions

View File

@@ -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)
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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 프롬프트에 활용 가능)

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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"));
}
}

View File

@@ -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();
}
}