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:
105
README.md
105
README.md
@@ -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"
|
||||
```
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user