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:
103
README.md
103
README.md
@@ -467,3 +467,106 @@ 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"
|
||||||
|
```
|
||||||
|
|||||||
@@ -63,5 +63,14 @@ public @interface McpFunction {
|
|||||||
boolean idempotentHint() default false;
|
boolean idempotentHint() default false;
|
||||||
boolean openWorldHint() 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)
|
// 추가: 툴 별 기본 Timeout 설정 (기본 300초 = 300000ms)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,5 +26,12 @@ import java.util.Map;
|
|||||||
public class McpProperties {
|
public class McpProperties {
|
||||||
|
|
||||||
private String namespace;
|
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;
|
package %s.dto;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import io.shinhanlife.dap.lib.annotation.McpOutputSchema;
|
||||||
|
import io.shinhanlife.dap.lib.annotation.McpValidation;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,16 +185,22 @@ public class ToolScaffolder {
|
|||||||
* </pre>
|
* </pre>
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
|
@McpOutputSchema
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
public class %sResponse {
|
public class %sResponse {
|
||||||
private String status;
|
@McpValidation(required = true)
|
||||||
private String message;
|
private String resultCode;
|
||||||
// TODO: Add response fields here
|
|
||||||
|
@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);
|
""".formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName);
|
||||||
Files.writeString(dtoDir.resolve(baseName + "Response.java"), resContent);
|
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 = """
|
String serviceInterfaceContent = """
|
||||||
package %s.usecase;
|
package %s.usecase;
|
||||||
@@ -214,7 +222,10 @@ public class ToolScaffolder {
|
|||||||
mappingId = "%s",
|
mappingId = "%s",
|
||||||
register = %s,
|
register = %s,
|
||||||
requiresApproval = false,
|
requiresApproval = false,
|
||||||
openWorldHint = true
|
openWorldHint = true,
|
||||||
|
version = "1.0.0",
|
||||||
|
timeoutMillis = 300000L,
|
||||||
|
enabled = true
|
||||||
)
|
)
|
||||||
Object execute(%sRequest req);
|
Object execute(%sRequest req);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ public class ToolMetadata {
|
|||||||
// 1. Tool 기본 정보
|
// 1. Tool 기본 정보
|
||||||
private String uid; // UUID 형식의 고유 식별자
|
private String uid; // UUID 형식의 고유 식별자
|
||||||
private String semver; // 버전 (예: 1.0.0)
|
private String semver; // 버전 (예: 1.0.0)
|
||||||
|
private Long timeoutMillis;
|
||||||
|
private Boolean enabled;
|
||||||
private String displayName; // 사람이 읽는 라벨 (1-128자)
|
private String displayName; // 사람이 읽는 라벨 (1-128자)
|
||||||
private String name; // MCP 서브툴 명칭 (64자 이하, 예: CustomerSearchTool)
|
private String name; // MCP 서브툴 명칭 (64자 이하, 예: CustomerSearchTool)
|
||||||
private String description; // 툴의 목적 및 설명 (LLM 프롬프트에 활용 가능)
|
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.setUid(UUID.nameUUIDFromBytes(subToolName.getBytes()).toString());
|
||||||
meta.setDisplayName(baseName);
|
meta.setDisplayName(baseName);
|
||||||
meta.setName(subToolName);
|
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.setDescription(functionAnnotation.description());
|
||||||
meta.setCategoryKey(toolAnnotation.categoryKey());
|
meta.setCategoryKey(toolAnnotation.categoryKey());
|
||||||
meta.setIntegrationType(toolAnnotation.routingType());
|
meta.setIntegrationType(toolAnnotation.routingType());
|
||||||
meta.setMciServiceId(functionAnnotation.mappingId());
|
meta.setMciServiceId(functionAnnotation.mappingId());
|
||||||
meta.setPodUrl(podUrl);
|
meta.setPodUrl(podUrl);
|
||||||
|
meta.setEndpoint(podUrl.replaceAll("/+$", "") + "/mcp/" + subToolName);
|
||||||
|
|
||||||
boolean isVisible = functionAnnotation.visible();
|
boolean isVisible = functionAnnotation.visible();
|
||||||
meta.setVisible(isVisible);
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@ logging:
|
|||||||
org.apache.kafka: ERROR
|
org.apache.kafka: ERROR
|
||||||
mcp:
|
mcp:
|
||||||
namespace: ""
|
namespace: ""
|
||||||
|
manifest:
|
||||||
|
bundle-id: tool-oth
|
||||||
|
# Set the AA-assigned prefix before MCP pull activation (for example: oth.).
|
||||||
|
name-prefix: ""
|
||||||
security:
|
security:
|
||||||
tenant-domains:
|
tenant-domains:
|
||||||
TESTER-DEV: ALL
|
TESTER-DEV: ALL
|
||||||
@@ -10,6 +10,10 @@ logging:
|
|||||||
org.apache.kafka: ERROR
|
org.apache.kafka: ERROR
|
||||||
mcp:
|
mcp:
|
||||||
namespace: ""
|
namespace: ""
|
||||||
|
manifest:
|
||||||
|
bundle-id: tool-sms
|
||||||
|
# Set the AA-assigned prefix before MCP pull activation (for example: sms.).
|
||||||
|
name-prefix: ""
|
||||||
security:
|
security:
|
||||||
tenant-domains:
|
tenant-domains:
|
||||||
TESTER-DEV: ALL
|
TESTER-DEV: ALL
|
||||||
Reference in New Issue
Block a user