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

105
README.md
View File

@@ -466,4 +466,107 @@ MCP SDK 표준 tools/list, tools/call
---
문서에 없는 업무·보안·배포 기준은 임의로 추가하지 말고 AA 및 플랫폼 운영 기준과 먼저 합의합니다.
문서에 없는 업무·보안·배포 기준은 임의로 추가하지 말고 AA 및 플랫폼 운영 기준과 먼저 합의합니다.
## Input/Output Schema 작성 가이드
Tool Schema는 Agent가 Tool을 정확히 호출하고, 반환값의 의미를 일관되게 해석하도록 하는 계약입니다. 인증 정보·사번·주민번호 등 민감정보(PII)는 Input/Output Schema와 Tool 응답에 포함하지 않습니다.
### Input Schema
Input Schema는 Agent가 Tool에 전달하는 파라미터의 이름, 타입, 필수 여부, 허용값, 형식 등을 정의합니다.
적용 우선순위는 다음과 같습니다.
1. `inputSchemaResource` — 복잡한 규칙을 담은 JSON Schema 리소스
2. `inputSchema` — 어노테이션에 직접 선언한 JSON Schema
3. 요청 DTO 필드의 `@McpValidation` — 자동 JSON Schema 생성
단순한 요청 DTO는 `@McpValidation`만으로 관리합니다.
```java
public class ClaimSearchRequest {
@McpValidation(required = true, pattern = "^CLM[0-9]{13}$")
private String claimNo;
@McpValidation(minimum = 1, maximum = 100)
private Integer size;
}
```
### Output Schema
Output Schema는 Tool이 반환하는 결과의 타입과 의미를 정의합니다. `BusinessToolController`는 Tool 실행 후 반환값을 Output Schema 기준으로 검증합니다.
적용 우선순위는 다음과 같습니다.
1. `outputSchemaResource` — 조건부 필드·중첩 배열 등 복잡한 규칙을 담은 JSON Schema 리소스
2. `outputSchema` — 어노테이션에 직접 선언한 JSON Schema
3. 반환 DTO의 `@McpOutputSchema`와 필드 `@McpValidation` — 자동 JSON Schema 생성
4. 위 설정이 모두 없으면 Output Schema 검증을 수행하지 않음
따라서 단순한 응답은 별도 `outputSchemaResource` 없이 반환 DTO에 `@McpOutputSchema`를 선언하면 됩니다. `null`이 정상 값일 수 있는 필드는 `nullable = true`를 반드시 지정합니다.
```java
@McpOutputSchema
public class ClaimSearchResponse {
@McpValidation(required = true, allowedValues = {"SUCCESS", "FAILURE"})
private String resultCode;
@McpValidation(nullable = true, minimum = 0)
private Long approvedAmount;
}
```
### 복잡한 Schema는 Tool 모듈별 리소스로 관리
조건부 응답, 중첩 DTO, 배열 정렬 기준처럼 어노테이션만으로 표현하기 어려운 규칙은 Tool Core가 아니라 각 Tool 모듈의 리소스에 JSON Schema로 둡니다.
```text
src/main/resources/
└─ tool-schemas/
└─ {categoryKey}/
├─ claim-search-resource-input-schema.json
└─ claim-search-resource-output-schema.json
```
예를 들어 `categoryKey``cmm`이면 아래와 같이 선언합니다.
```java
@McpFunction(
name = "sample.claim.search.resource",
inputSchemaResource = "classpath:tool-schemas/cmm/claim-search-resource-input-schema.json",
outputSchemaResource = "classpath:tool-schemas/cmm/claim-search-resource-output-schema.json"
)
public ClaimSearchResponse search(ClaimSearchRequest request) {
// ...
}
```
`inputSchemaResource``outputSchemaResource`는 복잡한 경우에만 선언합니다. 단순한 Tool까지 JSON 파일을 별도 생성할 필요는 없습니다.
### Output 설계 규칙
- 코드와 표시용 라벨을 함께 반환합니다. 예: `status` + `statusLabel`
- `null`이 정상인 값은 의미를 설명에 명시하고 DTO에는 `nullable = true`를 설정합니다.
- 조건부 필드는 어떤 조건에서 값이 존재하는지 JSON Schema에 명시합니다.
- 배열은 정렬 기준을 설명에 명시합니다. 예: `접수일 내림차순`
- 목록 응답에는 추가 조회 여부를 나타내는 `hasMore`를 포함합니다.
- 민감정보는 마스킹보다 **응답에서 제외**하는 것을 우선합니다.
### 실행 로그 및 확인
Tool 실행이 끝나면 아래 로그는 Schema 정의가 아니라 **검증을 통과한 실제 최종 응답값**을 출력합니다.
```text
[Tool -> MCP Gateway] Output Schema Result: { ... }
```
따라서 로그에도 실제 응답이 남으므로, 응답 DTO와 Output Schema에 민감정보가 포함되지 않도록 설계해야 합니다.
스키마 리소스와 DTO 기반 자동 Schema는 아래 테스트로 함께 검증할 수 있습니다.
```powershell
.\gradlew.bat :dap-tool-oth:test --tests "io.shinhanlife.dap.mcc.biz.cmm.dto.ClaimSearchRequestSchemaTest"
```

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

View File

@@ -10,6 +10,10 @@ logging:
org.apache.kafka: ERROR
mcp:
namespace: ""
manifest:
bundle-id: tool-oth
# Set the AA-assigned prefix before MCP pull activation (for example: oth.).
name-prefix: ""
security:
tenant-domains:
TESTER-DEV: ALL

View File

@@ -10,6 +10,10 @@ logging:
org.apache.kafka: ERROR
mcp:
namespace: ""
manifest:
bundle-id: tool-sms
# Set the AA-assigned prefix before MCP pull activation (for example: sms.).
name-prefix: ""
security:
tenant-domains:
TESTER-DEV: ALL