initialize 시 routing hint 직렬화 방식변경
All checks were successful
Deploy Gateway / deploy (push) Successful in 2m38s

This commit is contained in:
2026-09-16 14:14:44 +09:00
parent 58d3014a0f
commit ec517e88ec
48 changed files with 1481 additions and 163 deletions

View File

@@ -148,7 +148,7 @@ Tool 호출 직전마다 `remainingMillis()`로 남은 예산을 계산해 read
## Protocol version 협상과 검증
- 서버는 `mcp.protocol.supported-versions``mcp.protocol.preferred-version`으로 지원 버전을 명시적으로 관리한다. preferred version은 반드시 supported versions에 포함되어야 한다.
- `initialize` 응답은 요청의 JSON-RPC `id`를 그대로 반환하며, preferred version과 `serverInfo(name/title/version)`, `capabilities.tools.listChanged=false`를 제공한다.
- `initialize` 응답은 요청의 JSON-RPC `id`를 그대로 반환하며, preferred version과 `serverInfo(name/title/version)`, `capabilities.tools.listChanged=false`를 제공한다. Agent routing hint는 선택 정보이므로 Portal 또는 Tool Server 조회가 실패하면 `_meta.toolServers`만 생략하고 기본 initialize 응답은 정상 반환한다. 성공한 routing manifest는 필드 구조를 유지하면서 Jackson 전용 tree가 아닌 Map/List 기반 일반 JSON 값으로 바꿔 HTTP converter 구현과 분리한다.
- 이 서버는 stateless이므로 협상 결과를 session에 저장하지 않는다. `initialize` 이후 Agent Builder는 모든 MCP HTTP 요청에 `MCP-Protocol-Version: <initialize 응답 protocolVersion>`을 포함해야 하며, 서버는 매 요청을 독립적으로 검증한다.
- header가 누락되거나 지원하지 않는 값이면 JSON-RPC error가 아닌 HTTP `400 Bad Request`를 반환한다. 오류 body는 `error`, `message`, `supportedVersions`, `guid`를 포함해 호출자가 올바른 header를 진단할 수 있게 한다.

View File

@@ -0,0 +1,13 @@
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{
"type": "text",
"text": "{\"reasonCode\":\"INVALID_TOOL_ARGUMENTS\",\"message\":\"Tool arguments do not match the required schema.\"}"
}
],
"isError": true
}
}

View File

@@ -0,0 +1,253 @@
# DISC-20260827-001 요청 기반 TTL Refresh 설계 메모
- 상태: Open
- 논의 기준일: 2026-08-27
- 보안 등급: 내부용
- 적용 여부: 적용, scheduler polling 제거 후 요청 시점 TTL refresh 사용
이 문서는 MCP가 포털 registry와 Tool Server manifest를 주기적으로 polling하지 않고, Agent 요청 시점에 필요한 경우만 갱신하는 방안을 보존한다. 현재 구현은 이 방향을 적용해 기동 preload 이후 scheduler polling을 수행하지 않는다.
## 이름
요청 기반 TTL Refresh(Request-driven TTL Refresh)
## 배경
현재 MCP는 서버 기동 시 포털과 Tool Server를 조회하고, 이후 scheduler를 통해 포털 registry와 Tool Server manifest를 주기적으로 다시 호출한다. 이 방식은 단순하지만 요청이 없어도 외부 호출이 계속 발생한다.
검토 중인 대안은 scheduler와 배치를 사용하지 않고, Agent가 `tools/list` 또는 `tools/call`을 요청할 때 마지막 확인 시각을 기준으로 TTL이 만료되었는지 판단한 뒤 필요한 외부 API만 다시 호출하는 방식이다.
## 목표
1. 포털 registry와 Tool Server manifest의 주기 scheduler 호출을 제거하거나 비활성화한다.
2. 요청이 없는 route에 대해서는 외부 호출을 발생시키지 않는다.
3. TTL 안에서는 기존 in-memory snapshot을 즉시 사용한다.
4. TTL이 지난 첫 요청에서 포털과 Tool Server 원천을 best-effort로 재확인한다.
5. refresh 실패 시 기존 snapshot을 유지해 요청 경로의 안정성을 보존한다.
## 비목표
1. Redis Stream이나 Pub/Sub 기반 실시간 이벤트 동기화는 이 문서의 직접 구현 대상이 아니다.
2. 포털이나 Tool Server에 revision 전용 API를 새로 요구하지 않는다.
3. Agent가 Tool을 선택하는 방식이나 Tool Service의 업무 권한 처리는 변경하지 않는다.
4. MCP가 Tool을 자동 선택하거나 대체 Tool을 추천하지 않는다.
## 기본 아이디어
서버 기동 시에는 현재와 같이 최초 원천 조회를 수행한다.
```text
MCP 기동
-> 포털 registry API 호출
-> routeKey별 Tool Server endpoint 목록 확보
-> Tool Server manifest API 호출
-> routeKey별 Tool 목록과 실행 endpoint 확보
-> in-memory snapshot 저장
-> 마지막 확인 시각 저장
```
이후에는 scheduler가 아니라 Agent 요청이 들어왔을 때 TTL을 확인한다.
```text
Agent 요청: POST /mcp/{routeKey}
-> routeKey 검증
-> 포털 registry 마지막 확인 시각 확인
-> Tool Server manifest 마지막 확인 시각 확인
-> TTL 미만이면 기존 memory snapshot 사용
-> TTL 초과이면 기존 API를 다시 호출해 revision 비교
-> 변경 있으면 snapshot 교체
-> 변경 없으면 마지막 확인 시각만 갱신
-> 요청 처리 계속 진행
```
## 포털 처리 방식
포털에는 revision 전용 API가 없다고 가정한다. 따라서 서버 기동 시 호출하던 포털 registry API를 TTL 만료 시 다시 호출하고, 응답 body 안의 registry revision을 비교한다.
```text
포털 TTL 만료
-> 기존 포털 registry API 호출
-> registryRevision 비교
-> 같으면 endpoint snapshot 유지, lastCheckedAt 갱신
-> 다르면 endpoint snapshot 교체
-> 포털 변경이 있었던 route는 Tool Server manifest 강제 재조회 대상이 됨
```
포털 API 호출 실패 시 정책은 다음과 같다.
```text
기존 endpoint snapshot 있음
-> WARN 로그만 남김
-> 기존 endpoint snapshot 유지
기존 endpoint snapshot 없음
-> Redis portal registry fallback 시도
-> Redis도 없으면 registry unavailable
```
## Tool Server 처리 방식
Tool Server에도 revision 전용 API가 없다고 가정한다. 따라서 서버 기동 시 호출하던 Tool Server manifest API를 TTL 만료 시 다시 호출하고, 응답 body 안의 manifest revision 또는 그에 준하는 값을 비교한다.
```text
Tool manifest TTL 만료
-> 기존 Tool Server manifest API 호출
-> manifest revision 비교
-> 같으면 Tool snapshot 유지, lastCheckedAt 갱신
-> 다르면 Tool snapshot 교체
```
Tool Server manifest 호출 실패 시 정책은 다음과 같다.
```text
기존 Tool snapshot 있음
-> WARN 로그만 남김
-> 기존 Tool snapshot 유지
기존 Tool snapshot 없음
-> Redis route별 Tool snapshot fallback 시도
-> Redis도 없으면 registry unavailable
```
## 포털 변경과 Tool manifest 변경의 관계
포털과 Tool Server는 서로 다른 원천을 가진다.
```text
포털
-> routeKey별 Tool Server serviceDomain, manifestPath, serviceKey 관리
Tool Server manifest
-> Tool name, title, description, inputSchema, 실행 endpoint, manifest revision 관리
```
포털 revision이 바뀌면 Tool Server endpoint나 manifestPath가 바뀌었을 수 있으므로, Tool manifest TTL이 아직 남아 있어도 영향 route의 manifest는 다시 조회하는 것이 안전하다.
반대로 포털 revision이 같아도 Tool Server 내부 Tool 목록은 바뀔 수 있으므로, Tool manifest TTL이 만료되면 manifest API는 별도로 다시 호출해야 한다.
## 요청 처리 정책
`tools/list``tools/call` 모두 실제 처리 전에 같은 fresh check를 수행하는 방향을 검토한다.
```text
tools/list
-> ensureFreshIfExpired(routeKey)
-> listTools(routeKey)
-> Agent에 Tool 목록 반환
tools/call
-> ensureFreshIfExpired(routeKey)
-> findEnabledTool(routeKey, toolName)
-> inputSchema 검증
-> Tool Server 호출
```
이렇게 해야 오래된 Tool 목록과 오래된 실행 endpoint를 동시에 줄일 수 있다.
## 동시 요청 처리
TTL이 지난 시점에 같은 route로 요청이 여러 개 몰리면 외부 API 중복 호출이 발생할 수 있다. route별 single-flight를 적용해 첫 요청 하나만 refresh를 수행하고 나머지 요청은 같은 결과를 기다리거나 기존 snapshot을 사용하도록 정책을 정해야 한다.
우선 검토안은 다음과 같다.
```text
같은 route refresh 진행 중
-> 다른 요청은 진행 중인 refresh 결과를 기다림
-> refresh timeout은 짧게 유지
-> 실패하면 기존 snapshot으로 진행
```
## 실패 처리 원칙
TTL refresh는 최신화를 위한 보조 동작이지, 기존 정상 snapshot을 비우는 동작이 아니다.
```text
원천 조회 성공
-> revision 비교
-> 변경 시 memory snapshot 교체
-> Redis 저장은 best-effort
원천 조회 실패 + 기존 memory 있음
-> 기존 memory 유지
-> 요청 처리 계속
원천 조회 실패 + 기존 memory 없음
-> Redis fallback 시도
-> Redis도 실패하면 registry unavailable
```
## stale Tool 보정과의 관계
이미 검토한 404/410 stale Tool refresh는 유지한다.
```text
Tool call 중 upstream 404 또는 410 발생
-> 삭제되었거나 더 이상 제공되지 않는 Tool일 수 있음
-> TTL과 무관하게 해당 route manifest refresh 시도
-> 현재 요청은 Tool 실행 실패로 응답
-> 다음 요청부터 최신 snapshot 사용 가능
```
이 기능은 삭제된 Tool 호출에 대한 즉시 보정이고, 요청 기반 TTL Refresh는 추가·수정·endpoint 변경까지 포함한 일반 갱신 정책이다.
## 장점
1. 요청이 없는 동안 포털과 Tool Server 호출이 발생하지 않는다.
2. scheduler thread와 주기 설정 부담이 줄어든다.
3. route별로 실제 사용되는 대상만 갱신할 수 있다.
4. 기존 in-memory snapshot, Redis fallback, single-flight 구조를 재사용할 수 있다.
5. Redis Stream 이벤트 기반 구조로 가기 전 중간 단계로 적용하기 쉽다.
## 단점과 주의점
1. TTL이 지난 뒤 첫 Agent 요청은 외부 API 확인 때문에 느려질 수 있다.
2. 변경 반영은 즉시가 아니라 최대 TTL만큼 지연될 수 있다.
3. revision 전용 API가 없으므로 변경 확인만 하려 해도 기존 API 전체 body를 다시 받아야 한다.
4. 같은 route 요청이 동시에 들어올 때 중복 refresh 방어가 필요하다.
5. scheduler가 사라지면 요청이 전혀 없는 route는 갱신되지 않는다.
## 설정 후보
```yaml
mcp:
registry:
refresh-mode: request-ttl
refresh-ttl-seconds: 300
refresh-on-startup: true
portal:
refresh-ttl-seconds: 300
discovery:
manifest-refresh-ttl-seconds: 300
```
현재 구현 설정 이름은 `refresh-ttl-seconds`다. scheduler 모드는 유지하지 않는다.
## 구현 후보
후보 메서드 이름은 다음과 같다.
```java
registryService.ensureFreshIfExpired(routeKey);
```
예상 호출 위치는 다음과 같다.
```text
ToolsListHandler.handle()
ToolExecutionService.execute()
```
기존 scheduler는 제거하고 기동 preload만 남긴다.
## 확정 전 확인할 질문
1. TTL 기본값은 5분으로 할지, 운영에서 별도 값으로 둘지.
2. TTL 만료 시 refresh를 요청이 기다릴지, 기존 snapshot으로 먼저 응답하고 비동기 refresh할지.
3. 포털 registry revision 필드명과 Tool Server manifest revision 필드명이 무엇인지.
4. 포털 revision 변경 시 모든 route를 다시 볼지, 변경된 route만 볼 수 있는지.
5. scheduler 모드는 제거하는 것으로 확정했다.
6. Redis 도입 후 이 방식은 fallback으로 남길지, 이벤트 기반으로 대체할지.
## 현재 판단
이 방식은 Redis Stream 이벤트 기반 구조가 도입되기 전까지 외부 호출량을 줄이고 route별 최신성을 일정 수준 유지하는 중간 구현이다.

View File

@@ -52,7 +52,7 @@ public record McpProperties(
public McpProperties {
bundles = bundles == null ? List.of() : List.copyOf(bundles);
registry = registry == null
? new Registry("file:./config/local-core-tools-manifest-sample-v1.json", 300, 5)
? new Registry("classpath:/config/local-core-tools-manifest-sample-v1.json", 300, 5)
: registry;
toolClient = toolClient == null ? ToolClient.defaults() : toolClient;
portal = portal == null ? new Portal(false, "", "", 300) : portal;

View File

@@ -12,7 +12,7 @@ import org.springframework.stereotype.Component;
/**
* @package io.shinhanlife.dat.biz.mcp.execute
* @className ToolArgumentValidator
* @description Registry metadata에 정의된 MCP SDK JSON Schema 2020-12 규칙으로 Tool arguments를 검증합니다. Registry 기반 실행 계획을 만들 때 호출되며, 형식 위반은 upstream Tool Service 호출 전에 Invalid params 오류로 끝냅니다. 주요 의존성은 JSON 변환과 크기 계산용 {@link ObjectMapper}, MCP SDK {@link JsonSchemaValidator}, 실행 정책 원천인 {@link ToolMetadata}, 호출 정보인 {@link ToolCall}입니다.
* @description Registry metadata에 정의된 MCP SDK JSON Schema 2020-12 규칙으로 Tool arguments를 검증합니다. Registry 기반 실행 계획을 만들 때 호출되며, 인자 불일치는 upstream Tool Service 호출 전 Agent가 수정 가능한 Tool 오류로 분류합니다. 주요 의존성은 JSON 변환과 크기 계산용 {@link ObjectMapper}, MCP SDK {@link JsonSchemaValidator}, 실행 정책 원천인 {@link ToolMetadata}, 호출 정보인 {@link ToolCall}입니다.
* @author j.h.w
* @create 2026.08.06
*
@@ -21,6 +21,7 @@ import org.springframework.stereotype.Component;
* 수정일 수정자 수정내용
* ---------- ---------- ----------------
* 2026.08.06 j.h.w 최초생성
* 2026.09.09 j.h.w Tool 인자 불일치와 잘못된 Tool schema 오류 분리
*
* </pre>
*/
@@ -42,7 +43,7 @@ public class ToolArgumentValidator {
}
/**
* Registry에서 찾은 Tool의 {@code inputSchema}를 호출 arguments에 적용합니다. {@link ToolExecutionService}가 HTTP routing 전에 호출하므로 실패하면 Tool Service에는 요청이 전송되지 않으며, 위반 내용은 기존 외부 계약인 {@code -32602 Invalid params}로 변환됩니다.
* Registry에서 찾은 Tool의 {@code inputSchema}를 호출 arguments에 적용합니다. {@link ToolExecutionService}가 HTTP routing 전에 호출하므로 실패하면 Tool Service에는 요청이 전송되지 않으며, 인자 불일치는 Agent가 수정할 수 있는 Tool 실행 오류로 분류됩니다.
*
* @param call Tool 처리 정보입니다.
* @param metadata Tool 처리 정보입니다.
@@ -52,7 +53,7 @@ public class ToolArgumentValidator {
}
/**
* Registry inputSchema를 MCP SDK 검증기에 전달해 JSON Schema 2020-12 keyword를 검사합니다. 기존 외부 계약을 보존하기 위해 검증 실패는 SDK의 Tool result가 아니라 최상위 Invalid params 예외로 변환합니다. SDK 원문 오류는 입력값을 포함할 수 있으므로 외부에는 고정된 안전 메시지만 제공합니다.
* Registry inputSchema를 MCP SDK 검증기에 전달해 JSON Schema 2020-12 keyword를 검사합니다. arguments 불일치는 Tool result 오류로 구분하고 SDK 원문은 외부에 공개하지 않습니다. Tool Server가 object가 아닌 schema를 제공하면 Agent 인자로 고칠 수 없는 서버 구성 오류로 분류합니다.
*
* @param call Tool 처리 정보입니다.
* @param schema 입력값입니다.
@@ -68,7 +69,7 @@ public class ToolArgumentValidator {
JsonSchemaValidator.ValidationResponse validation =
jsonSchemaValidator.validate(schemaMap, arguments);
if (!validation.valid()) {
throw invalid("arguments do not match inputSchema");
throw invalidArguments("arguments do not match inputSchema");
}
}
@@ -80,7 +81,7 @@ public class ToolArgumentValidator {
*/
private void validateStableContract(ToolCall call, JsonNode schema) {
if (schema.has("type") && !"object".equals(schema.path("type").asText())) {
throw invalid("Only object inputSchema is supported by this adapter");
throw invalidToolSchema("Only object inputSchema is supported by this adapter");
}
JsonNode required = schema.path("required");
if (required.isArray()) {
@@ -88,7 +89,7 @@ public class ToolArgumentValidator {
field -> {
String name = field.asText();
if (!call.arguments().has(name) || call.arguments().get(name).isNull()) {
throw invalid("'" + name + "' is required");
throw invalidArguments("'" + name + "' is required");
}
});
}
@@ -129,17 +130,27 @@ public class ToolArgumentValidator {
default -> true;
};
if (!valid) {
throw invalid(field + " must be of type " + type);
throw invalidArguments(field + " must be of type " + type);
}
}
/**
* 검증 실패 이유를 Invalid params JSON-RPC 예외로 통일합니다.
* Agent가 arguments를 고쳐 재호출할 수 있는 inputSchema 불일치를 Tool 인자 오류로 만듭니다. 상세 검증 문구는 내부 진단에만 사용되고 공개 응답에서는 고정 문구로 치환됩니다.
*
* @param details 처리할 값입니다.
* @return 처리 결과를 반환합니다.
*/
private JsonRpcException invalid(String details) {
return new JsonRpcException(JsonRpcErrorCode.INVALID_PARAMS, details);
private JsonRpcException invalidArguments(String details) {
return new JsonRpcException(JsonRpcErrorCode.TOOL_ARGUMENT_ERROR, details);
}
/**
* Tool Server가 MCP adapter에서 지원하지 않는 inputSchema를 제공한 경우 Agent가 고칠 수 없는 내부 구성 오류로 만듭니다. 실제 schema 내용은 공개 응답에 포함되지 않습니다.
*
* @param details 내부 진단에 사용할 schema 오류 설명입니다.
* @return 공개 단계에서 INTERNAL_ERROR로 정제될 JSON-RPC 예외입니다.
*/
private JsonRpcException invalidToolSchema(String details) {
return new JsonRpcException(JsonRpcErrorCode.INTERNAL_ERROR, details);
}
}

View File

@@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.JsonNode;
import io.shinhanlife.dat.biz.mcp.context.McpRequestContext;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
import io.shinhanlife.dat.biz.mcp.jsonrpc.PublicErrorReason;
import io.shinhanlife.dat.biz.mcp.jsonrpc.SafeError;
import io.shinhanlife.dat.biz.mcp.observability.TraceLogger;
import io.shinhanlife.dat.biz.mcp.registry.ToolMetadata;
import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryService;
@@ -28,6 +30,7 @@ import org.springframework.stereotype.Service;
* 수정일 수정자 수정내용
* ---------- ---------- ----------------
* 2026.08.06 j.h.w 최초생성
* 2026.09.09 j.h.w Tool client 원문 대신 공개 오류 분류 전달
*
* </pre>
*/
@@ -113,7 +116,7 @@ public class ToolExecutionService {
attempt);
refreshRouteOnStaleToolSignal(context.routeKey(), toolRequest, exception);
if (!shouldRetry(toolRequest, exception, attempt)) {
throw mapException(exception, toolRequest);
throw mapException(exception);
}
traceLogger.event(
"tool_http_request_retrying",
@@ -124,7 +127,7 @@ public class ToolExecutionService {
"backoffMillis",
toolRequest.backoffMillis());
if (!sleepBeforeRetry(toolRequest.backoffMillis())) {
throw mapException(exception, toolRequest);
throw mapException(exception);
}
attempt++;
}
@@ -242,21 +245,27 @@ public class ToolExecutionService {
* Tool client 실패 종류를 timeout·권한·실행 JSON-RPC 코드로 일관되게 변환합니다.
*
* @param exception 처리 중 발생한 예외 정보입니다.
* @param request 처리할 요청 정보입니다.
* @return 처리 결과를 반환합니다.
*/
private JsonRpcException mapException(ToolClientException exception, ToolRequest request) {
JsonRpcErrorCode code =
private JsonRpcException mapException(ToolClientException exception) {
SafeError safeError =
switch (exception.kind()) {
case TIMEOUT -> JsonRpcErrorCode.TOOL_TIMEOUT;
case UNAUTHORIZED -> JsonRpcErrorCode.UNAUTHORIZED;
case FORBIDDEN -> JsonRpcErrorCode.FORBIDDEN;
case NETWORK, EXECUTION -> JsonRpcErrorCode.TOOL_EXECUTION_ERROR;
case TIMEOUT -> new SafeError(
JsonRpcErrorCode.TOOL_TIMEOUT, PublicErrorReason.TOOL_TIMEOUT);
case NETWORK -> new SafeError(
JsonRpcErrorCode.TOOL_EXECUTION_ERROR,
PublicErrorReason.TOOL_UNAVAILABLE);
case UNAUTHORIZED -> new SafeError(
JsonRpcErrorCode.UNAUTHORIZED,
PublicErrorReason.TOOL_UNAUTHORIZED);
case FORBIDDEN -> new SafeError(
JsonRpcErrorCode.FORBIDDEN, PublicErrorReason.TOOL_FORBIDDEN);
case EXECUTION -> new SafeError(
JsonRpcErrorCode.TOOL_EXECUTION_ERROR,
PublicErrorReason.TOOL_EXECUTION_FAILED);
};
return new JsonRpcException(
code,
request.toolName() + "@" + request.version() + ": " + exception.getMessage(),
exception);
safeError.protocolCode(), safeError, null, exception);
}
/**

View File

@@ -14,6 +14,7 @@ import io.modelcontextprotocol.spec.McpSchema;
* 수정일 수정자 수정내용
* ---------- ---------- ----------------
* 2026.08.06 j.h.w 최초생성
* 2026.09.09 j.h.w Tool inputSchema 불일치 내부 분류 추가
*
* </pre>
*/
@@ -28,7 +29,8 @@ public enum JsonRpcErrorCode {
TOOL_TIMEOUT(-32002, "Tool timeout"),
TOOL_REGISTRY_UNAVAILABLE(-32003, "Tool registry unavailable"),
UNAUTHORIZED(-32004, "Unauthorized"),
FORBIDDEN(-32005, "Forbidden");
FORBIDDEN(-32005, "Forbidden"),
TOOL_ARGUMENT_ERROR(-32006, "Tool argument error");
private final int code;
private final String message;

View File

@@ -19,6 +19,7 @@ import java.util.Map;
* 수정일 수정자 수정내용
* ---------- ---------- ----------------
* 2026.08.06 j.h.w 최초생성
* 2026.09.09 j.h.w 공개 오류만 직렬화하도록 SafeError 응답 계약 적용
*
* </pre>
*/
@@ -43,35 +44,34 @@ public record JsonRpcResponse(String jsonrpc, Object result, Error error, JsonNo
* @param error 처리 중 발생한 예외 정보입니다.
* @return Agent Builder로 반환할 JSON-RPC 응답입니다.
*/
public static JsonRpcResponse failure(JsonNode id, Error error) {
private static JsonRpcResponse failure(JsonNode id, Error error) {
return new JsonRpcResponse(McpSchema.JSONRPC_VERSION, null, error, id);
}
/**
* 내부 오류 코드와 상세 정보를 guid가 포함된 JSON-RPC 실패 응답으로 변환합니다.
* 안전한 공개 오류를 guid가 포함된 JSON-RPC 실패 응답으로 변환합니다. 내부 예외 message나 임의 details는 입력받지 않아 Agent Builder 응답으로 노출될 통로를 차단합니다.
*
* @param id 입력값입니다.
* @param code 입력값입니다.
* @param details 처리할 값입니다.
* @param safeError 공개가 허용된 오류 코드와 고정 문구입니다.
* @return Agent Builder로 반환할 JSON-RPC 응답입니다.
*/
public static JsonRpcResponse failure(JsonNode id, JsonRpcErrorCode code, Object details) {
public static JsonRpcResponse failure(JsonNode id, SafeError safeError) {
SafeError normalized = safeError == null ? SafeError.internalError() : safeError;
Map<String, Object> data = new LinkedHashMap<>();
McpRequestContextHolder.get()
.map(context -> context.guid())
.ifPresent(guid -> data.put("guid", guid));
if (details != null) {
data.put("details", details);
}
String message =
code == JsonRpcErrorCode.INVALID_PARAMS && details != null
? code.message() + ": " + details
: code.message();
return failure(id, new Error(code.code(), message, data));
data.putAll(normalized.publicData());
return failure(
id,
new Error(
normalized.protocolCode().code(),
normalized.protocolCode().message(),
data));
}
/**
* JSON-RPC 오류의 code·message·선택 data를 담는 하위 값 객체입니다. {@link JsonRpcResponse#failure(JsonNode, JsonRpcErrorCode, Object)}가 생성하며, Tool 업무 실패 결과에는 사용하지 않습니다.
* JSON-RPC 오류의 code·message·선택 data를 담는 하위 값 객체입니다. {@link JsonRpcResponse#failure(JsonNode, SafeError)}가 생성하며, Tool 업무 실패 결과에는 사용하지 않습니다.
*
* @param code 입력값입니다.
* @param message 처리할 값입니다.

View File

@@ -0,0 +1,73 @@
package io.shinhanlife.dat.biz.mcp.jsonrpc;
/**
* @package io.shinhanlife.dat.biz.mcp.jsonrpc
* @className PublicErrorMapper
* @description 내부 JSON-RPC 예외와 오류 코드를 Agent Builder 공개용 {@link SafeError}로 변환합니다. 요청을 직접 처리하지 않으며, controller·filter·Tool handler가 동일한 공개 정책을 사용하고 알 수 없는 오류는 INTERNAL_ERROR로 닫히도록 하는 중앙 방어 계층입니다.
* @author j.h.w
* @create 2026.09.09
*
* <pre>
* ============ 개정이력 ============
* 수정일 수정자 수정내용
* ---------- ---------- ----------------
* 2026.09.09 j.h.w 최초생성
*
* </pre>
*/
public final class PublicErrorMapper {
/**
* 상태를 갖지 않는 정적 변환기이므로 인스턴스 생성을 막습니다.
*/
private PublicErrorMapper() {
}
/**
* JSON-RPC 예외를 공개 오류로 변환합니다. 이미 SafeError가 담긴 경우 코드 일치 여부를 확인해 사용하고, 문자열·객체 형태의 기존 errorData는 폐기한 뒤 오류 코드에 대응하는 고정 문구를 사용합니다.
*
* @param exception 처리 계층에서 전달된 JSON-RPC 예외입니다.
* @return Agent Builder 응답에 사용 가능한 안전한 오류입니다.
*/
public static SafeError from(JsonRpcException exception) {
if (exception == null) {
return SafeError.internalError();
}
try {
if (exception.errorData() instanceof SafeError safeError
&& safeError.protocolCode() == exception.errorCode()) {
return safeError;
}
return from(exception.errorCode());
} catch (RuntimeException mappingFailure) {
return SafeError.internalError();
}
}
/**
* JSON-RPC 오류 코드를 공개 reason과 고정 문구로 변환하며, null 또는 향후 매핑되지 않은 코드는 INTERNAL_ERROR로 처리합니다.
*
* @param code 내부 처리 계층이 결정한 JSON-RPC 오류 코드입니다.
* @return Agent Builder 응답에 사용할 안전한 오류입니다.
*/
public static SafeError from(JsonRpcErrorCode code) {
if (code == null) {
return SafeError.internalError();
}
return switch (code) {
case PARSE_ERROR -> new SafeError(code, PublicErrorReason.MALFORMED_JSON);
case INVALID_REQUEST -> new SafeError(code, PublicErrorReason.INVALID_REQUEST);
case METHOD_NOT_FOUND -> new SafeError(code, PublicErrorReason.METHOD_NOT_SUPPORTED);
case INVALID_PARAMS -> new SafeError(code, PublicErrorReason.INVALID_PARAMS);
case TOOL_EXECUTION_ERROR -> new SafeError(code, PublicErrorReason.TOOL_EXECUTION_FAILED);
case TOOL_NOT_FOUND -> new SafeError(code, PublicErrorReason.TOOL_NOT_FOUND);
case TOOL_TIMEOUT -> new SafeError(code, PublicErrorReason.TOOL_TIMEOUT);
case TOOL_REGISTRY_UNAVAILABLE -> new SafeError(code, PublicErrorReason.TOOL_REGISTRY_UNAVAILABLE);
case UNAUTHORIZED -> new SafeError(code, PublicErrorReason.TOOL_UNAUTHORIZED);
case FORBIDDEN -> new SafeError(code, PublicErrorReason.TOOL_FORBIDDEN);
case TOOL_ARGUMENT_ERROR -> new SafeError(code, PublicErrorReason.INVALID_TOOL_ARGUMENTS);
case INTERNAL_ERROR -> SafeError.internalError();
default -> SafeError.internalError();
};
}
}

View File

@@ -0,0 +1,64 @@
package io.shinhanlife.dat.biz.mcp.jsonrpc;
/**
* @package io.shinhanlife.dat.biz.mcp.jsonrpc
* @className PublicErrorReason
* @description Agent Builder에 공개할 수 있는 오류 식별자와 고정 문구를 정의합니다. HTTP/MCP 요청을 직접 처리하지 않으며, 내부 예외 메시지·주소·인증정보가 응답에 섞이지 않도록 {@link PublicErrorMapper}와 응답 생성 계층이 이 목록만 사용합니다.
* @author j.h.w
* @create 2026.09.09
*
* <pre>
* ============ 개정이력 ============
* 수정일 수정자 수정내용
* ---------- ---------- ----------------
* 2026.09.09 j.h.w 최초생성
*
* </pre>
*/
public enum PublicErrorReason {
MALFORMED_JSON("MALFORMED_JSON", "Request body is not valid JSON."),
INVALID_REQUEST("INVALID_REQUEST", "Request is invalid."),
METHOD_NOT_SUPPORTED("METHOD_NOT_SUPPORTED", "Requested method is not supported."),
INVALID_PARAMS("INVALID_PARAMS", "Request parameters are invalid."),
INVALID_TOOL_ARGUMENTS("INVALID_TOOL_ARGUMENTS", "Tool arguments do not match the required schema."),
TOOL_EXECUTION_FAILED("TOOL_EXECUTION_FAILED", "Tool execution failed."),
TOOL_NOT_FOUND("TOOL_NOT_FOUND", "Requested tool was not found."),
TOOL_TIMEOUT("TOOL_TIMEOUT", "Tool execution timed out."),
TOOL_UNAVAILABLE("TOOL_UNAVAILABLE", "Tool service is temporarily unavailable."),
TOOL_REGISTRY_UNAVAILABLE("TOOL_REGISTRY_UNAVAILABLE", "Tool registry is temporarily unavailable."),
TOOL_UNAUTHORIZED("TOOL_UNAUTHORIZED", "Tool service authentication failed."),
TOOL_FORBIDDEN("TOOL_FORBIDDEN", "Tool execution is not permitted."),
INTERNAL_ERROR("INTERNAL_ERROR", "Unexpected server error.");
private final String reasonCode;
private final String publicMessage;
/**
* 외부 공개용 식별자와 내부 정보를 포함하지 않는 고정 문구를 오류 종류에 연결합니다.
*
* @param reasonCode Agent Builder가 분기 판단에 사용할 안정적인 오류 식별자입니다.
* @param publicMessage Agent Builder와 LLM에 공개 가능한 고정 오류 문구입니다.
*/
PublicErrorReason(String reasonCode, String publicMessage) {
this.reasonCode = reasonCode;
this.publicMessage = publicMessage;
}
/**
* Agent Builder가 오류 종류를 기계적으로 구분할 공개 식별자를 반환합니다.
*
* @return 외부 공개용 오류 식별자입니다.
*/
public String reasonCode() {
return reasonCode;
}
/**
* 내부 예외 원문을 대신해 Agent Builder에 전달할 고정 오류 문구를 반환합니다.
*
* @return 외부 공개가 허용된 오류 문구입니다.
*/
public String publicMessage() {
return publicMessage;
}
}

View File

@@ -0,0 +1,57 @@
package io.shinhanlife.dat.biz.mcp.jsonrpc;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* @package io.shinhanlife.dat.biz.mcp.jsonrpc
* @className SafeError
* @description JSON-RPC 코드와 Agent Builder 공개용 오류만 결합한 불변 값입니다. 요청을 직접 처리하지 않으며, exception message·stack trace·외부 시스템 주소를 보관하지 않아 응답 생성 계층이 내부 오류 원문을 직렬화할 수 없게 합니다.
* @author j.h.w
* @create 2026.09.09
*
* <pre>
* ============ 개정이력 ============
* 수정일 수정자 수정내용
* ---------- ---------- ----------------
* 2026.09.09 j.h.w 최초생성
*
* </pre>
*/
public record SafeError(JsonRpcErrorCode protocolCode, PublicErrorReason reason) {
/**
* 공개 오류 구성값이 없거나 불완전하면 INTERNAL_ERROR로 치환하여 변환 계층 자체의 실패도 외부에 노출되지 않게 합니다.
*
* @param protocolCode JSON-RPC error envelope에 사용할 코드입니다.
* @param reason Agent Builder에 공개할 오류 식별자와 고정 문구입니다.
*/
public SafeError {
if (protocolCode == null || reason == null) {
protocolCode = JsonRpcErrorCode.INTERNAL_ERROR;
reason = PublicErrorReason.INTERNAL_ERROR;
}
}
/**
* JSON-RPC error data 또는 Tool 실패 text로 직렬화할 공개 필드만 반환합니다.
*
* @return reasonCode와 고정 메시지만 포함한 불변 Map입니다.
*/
public Map<String, String> publicData() {
Map<String, String> data = new LinkedHashMap<>();
data.put("reasonCode", reason.reasonCode());
data.put("message", reason.publicMessage());
return Collections.unmodifiableMap(data);
}
/**
* 매핑할 수 없는 오류에 사용할 최종 INTERNAL_ERROR 안전값을 반환합니다.
*
* @return 내부 정보가 없는 최종 fallback 오류입니다.
*/
public static SafeError internalError() {
return new SafeError(JsonRpcErrorCode.INTERNAL_ERROR, PublicErrorReason.INTERNAL_ERROR);
}
}

View File

@@ -1,6 +1,7 @@
package io.shinhanlife.dat.biz.mcp.method;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.spec.McpSchema;
import io.shinhanlife.dat.biz.mcp.config.AgentRoutingHintsProperties;
import io.shinhanlife.dat.biz.mcp.config.McpProperties;
@@ -10,13 +11,15 @@ import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcResponse;
import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryClient;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* @package io.shinhanlife.dat.biz.mcp.method
* @className InitializeHandler
* @description MCP lifecycle의 {@code initialize} 요청을 처리해 서버 정보, capability, 선택 protocol version을 응답합니다. 설정된 MCP POST endpoint에서 {@code McpController}가 method별로 이 handler를 선택하며, 새 mcp-session-id 헤더 발급은 HTTP transport의 책임입니다.
* @description MCP lifecycle의 {@code initialize} 요청을 처리해 서버 정보, capability, 선택 protocol version을 응답합니다. Agent routing hint는 선택 정보이므로 조회에 실패해도 기본 initialize 응답을 유지합니다. 설정된 MCP POST endpoint에서 {@code McpController}가 method별로 이 handler를 선택하며, 새 mcp-session-id 헤더 발급은 HTTP transport의 책임입니다.
* @author j.h.w
* @create 2026.08.06
*
@@ -25,15 +28,20 @@ import org.springframework.stereotype.Component;
* 수정일 수정자 수정내용
* ---------- ---------- ----------------
* 2026.08.06 j.h.w 최초생성
* 2026.09.16 j.h.w routing hint 실패를 격리해 기본 initialize 응답 유지
* 2026.09.16 j.h.w routing hint를 HTTP converter 독립적인 일반 JSON 값으로 변환
*
* </pre>
*/
@Component
public class InitializeHandler implements McpMethodHandlerRegistry.Handler {
private static final Logger log = LoggerFactory.getLogger(InitializeHandler.class);
private final McpProperties properties;
private final AgentRoutingHintsProperties routingHintsProperties;
private final ToolRegistryClient registryClient;
private final ObjectMapper objectMapper;
/**
* initialize 응답에 사용할 서버 정보와 protocol 설정을 주입받습니다. 테스트 호환을 위한 생성자이며 Agent routing hint는 비활성화합니다.
@@ -41,24 +49,27 @@ public class InitializeHandler implements McpMethodHandlerRegistry.Handler {
* @param properties MCP 설정 정보입니다.
*/
public InitializeHandler(McpProperties properties) {
this(properties, new AgentRoutingHintsProperties(false, "/tool-service-manifest"), null);
this(properties, new AgentRoutingHintsProperties(false, "/tool-service-manifest"), null, null);
}
/**
* initialize 응답에 사용할 서버 정보, protocol 설정, Agent routing hint 조회 port를 주입받습니다. routing hint가 켜진 경우에만 Registry client를 통해 현재 route의 Tool Server 설명 manifest를 읽습니다.
* initialize 응답에 사용할 서버 정보, protocol 설정, Agent routing hint 조회 port와 JSON 변환기를 주입받습니다. routing hint가 켜진 경우에만 Registry client를 통해 현재 route의 Tool Server 설명 manifest를 읽습니다.
*
* @param properties MCP 설정 정보입니다.
* @param routingHintsProperties Agent routing hint 설정 정보입니다.
* @param registryClient Tool Server routing manifest 조회 port입니다.
* @param objectMapper routing manifest를 일반 JSON 값으로 변환할 mapper입니다.
*/
@Autowired
public InitializeHandler(
McpProperties properties,
AgentRoutingHintsProperties routingHintsProperties,
ToolRegistryClient registryClient) {
ToolRegistryClient registryClient,
ObjectMapper objectMapper) {
this.properties = properties;
this.routingHintsProperties = routingHintsProperties;
this.registryClient = registryClient;
this.objectMapper = objectMapper;
}
/**
@@ -106,17 +117,31 @@ public class InitializeHandler implements McpMethodHandlerRegistry.Handler {
}
/**
* initialize 응답의 `_meta`에 들어갈 Agent routing hint wrapper를 만듭니다. Tool Server가 준 routing manifest JSON은 변환하지 않고 {@code toolServers} 배열로 감싸며, 기능이 꺼져 있거나 route가 없면 표준 initialize 응답만 유지합니다.
* initialize 응답의 `_meta`에 들어갈 Agent routing hint wrapper를 만듭니다. Tool Server가 준 JSON의 필드 구조는 유지하되 Jackson 전용 {@link JsonNode}를 일반 Map/List 값으로 변환해 HTTP converter 구현과 분리합니다. 기능이 꺼져 있거나 route가 없거나 선택 정보 조회가 실패하면 표준 initialize 응답만 유지합니다.
*
* @param routeKey 처리 대상 route key입니다.
* @return Agent Builder로 공개할 `_meta` map입니다.
*/
private Map<String, Object> routingHintMeta(String routeKey) {
if (!routingHintsProperties.enabled() || routeKey == null || registryClient == null) {
if (!routingHintsProperties.enabled()
|| routeKey == null
|| registryClient == null
|| objectMapper == null) {
return Map.of();
}
try {
List<JsonNode> routingManifests =
registryClient.fetchRoutingManifests(routeKey, routingHintsProperties.manifestPath());
List<Object> toolServers = routingManifests.stream()
.map(manifest -> objectMapper.convertValue(manifest, Object.class))
.toList();
return Map.of("toolServers", toolServers);
} catch (RuntimeException exception) {
log.warn(
"Agent routing hints unavailable; initialize continues without optional metadata. routeKey={}, reason={}",
routeKey,
exception.getClass().getSimpleName());
return Map.of();
}
List<JsonNode> routingManifests =
registryClient.fetchRoutingManifests(routeKey, routingHintsProperties.manifestPath());
return Map.of("toolServers", routingManifests);
}
}

View File

@@ -9,6 +9,7 @@ import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcResponse;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Component;
@@ -24,6 +25,7 @@ import org.springframework.stereotype.Component;
* 수정일 수정자 수정내용
* ---------- ---------- ----------------
* 2026.08.06 j.h.w 최초생성
* 2026.09.09 j.h.w notification용 non-throwing handler 조회 추가
*
* </pre>
*/
@@ -55,12 +57,22 @@ public class McpMethodHandlerRegistry {
* @return 처리 결과를 반환합니다.
*/
public Handler resolve(String method) {
Handler handler = handlers.get(method);
if (handler == null) {
throw new JsonRpcException(
JsonRpcErrorCode.METHOD_NOT_FOUND, "Unsupported MCP method: " + method);
}
return handler;
return find(method)
.orElseThrow(
() ->
new JsonRpcException(
JsonRpcErrorCode.METHOD_NOT_FOUND,
"Unsupported MCP method: " + method));
}
/**
* notification처럼 지원하지 않는 method도 오류 응답 없이 처리해야 하는 경로에서 handler를 조회합니다. handler가 없으면 예외 대신 빈 값을 반환하므로, 일반 request의 {@link #resolve(String)} 오류 계약에는 영향을 주지 않습니다.
*
* @param method 조회할 MCP method 이름입니다.
* @return 등록된 handler가 있으면 포함하고, 없으면 빈 값인 조회 결과입니다.
*/
public Optional<Handler> find(String method) {
return Optional.ofNullable(handlers.get(method));
}
/**

View File

@@ -1,6 +1,7 @@
package io.shinhanlife.dat.biz.mcp.method;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.spec.McpSchema;
import io.shinhanlife.dat.biz.mcp.context.McpRequestContext;
import io.shinhanlife.dat.biz.mcp.execute.ToolCall;
@@ -9,6 +10,8 @@ import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequest;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcResponse;
import io.shinhanlife.dat.biz.mcp.jsonrpc.PublicErrorMapper;
import io.shinhanlife.dat.biz.mcp.jsonrpc.SafeError;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
@@ -26,6 +29,7 @@ import org.springframework.util.StringUtils;
* 수정일 수정자 수정내용
* ---------- ---------- ----------------
* 2026.08.06 j.h.w 최초생성
* 2026.09.09 j.h.w Tool 실패를 고정 공개 오류 payload로 변환
*
* </pre>
*/
@@ -33,14 +37,17 @@ import org.springframework.util.StringUtils;
public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler {
private final ToolExecutionService executionService;
private final ObjectMapper objectMapper;
/**
* Tool 실행 서비스를 주입받습니다.
* Tool 실행 서비스와 공개 오류 payload 직렬화에 사용할 JSON mapper를 주입받습니다.
*
* @param executionService 협력 객체입니다.
* @param executionService Tool 실행 서비스입니다.
* @param objectMapper 공개 오류를 JSON text content로 변환할 mapper입니다.
*/
public ToolsCallHandler(ToolExecutionService executionService) {
public ToolsCallHandler(ToolExecutionService executionService, ObjectMapper objectMapper) {
this.executionService = executionService;
this.objectMapper = objectMapper;
}
/**
@@ -68,7 +75,8 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler {
return JsonRpcResponse.success(request.id(), successResult(result));
} catch (JsonRpcException exception) {
if (isToolExecutionFailure(exception.errorCode())) {
return JsonRpcResponse.success(request.id(), failureResult(exception.errorData()));
return JsonRpcResponse.success(
request.id(), failureResult(PublicErrorMapper.from(exception)));
}
throw exception;
}
@@ -131,13 +139,14 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler {
}
/**
* Tool 실패 상세를 사용자에게 전달 가능한 text content `isError=true` 결과로 변환합니다.
* Tool 실패를 reasonCode와 고정 문구만 담은 JSON text content `isError=true` 결과로 변환합니다. 내부 예외 message와 Tool Server 원문은 입력받지 않습니다.
*
* @param details 처리할 값입니다.
* @param safeError Agent Builder에 공개 가능한 오류입니다.
* @return 처리 결과를 반환합니다.
*/
private McpSchema.CallToolResult failureResult(Object details) {
McpSchema.TextContent content = McpSchema.TextContent.builder(String.valueOf(details)).build();
private McpSchema.CallToolResult failureResult(SafeError safeError) {
String publicErrorText = objectMapper.valueToTree(safeError.publicData()).toString();
McpSchema.TextContent content = McpSchema.TextContent.builder(publicErrorText).build();
return McpSchema.CallToolResult.builder(List.of(content)).isError(true).build();
}
@@ -151,6 +160,7 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler {
return errorCode == JsonRpcErrorCode.TOOL_EXECUTION_ERROR
|| errorCode == JsonRpcErrorCode.TOOL_TIMEOUT
|| errorCode == JsonRpcErrorCode.UNAUTHORIZED
|| errorCode == JsonRpcErrorCode.FORBIDDEN;
|| errorCode == JsonRpcErrorCode.FORBIDDEN
|| errorCode == JsonRpcErrorCode.TOOL_ARGUMENT_ERROR;
}
}

View File

@@ -297,7 +297,7 @@ public class PortalToolRegistryClient implements ToolRegistryClient {
String previous = lastPortalRevision.get();
boolean changed = previous == null || !previous.equals(revision);
if (changed && lastPortalRevision.compareAndSet(previous, revision)) {
log.info(
log.debug(
"Portal registry response accepted. registryUrl={} previousRevision={} registryRevision={} body={}",
registryUrl,
previous,

View File

@@ -288,7 +288,7 @@ public class ToolRegistryService implements McpRouteKeyValidator {
@Override
public synchronized void refreshSourceRegistryIfStale() {
boolean portalDue = isPortalRefreshDue();
log.info(
log.debug(
"TEMP_PORTAL_TTL_REFRESH_DECISION phase=route_validation portalDue={} action={}",
portalDue,
portalDue ? "refresh_portal_registry" : "skip");
@@ -313,7 +313,7 @@ public class ToolRegistryService implements McpRouteKeyValidator {
boolean portalChangeNewerThanManifest = isPortalChangeNewerThanManifest(normalizedRouteKey);
boolean manifestDue = isManifestRefreshDue(normalizedRouteKey);
boolean manifestRefresh = portalChanged || portalChangeNewerThanManifest || manifestDue;
log.info(
log.debug(
"TEMP_TTL_REFRESH_DECISION routeKey={} portalDue={} portalChanged={} "
+ "portalChangeNewerThanManifest={} manifestDue={} manifestRefresh={} action={}",
normalizedRouteKey,
@@ -453,7 +453,7 @@ public class ToolRegistryService implements McpRouteKeyValidator {
if (!changed) {
return;
}
log.info(
log.debug(
"Tool registry in-memory snapshot registered. body={}",
snapshotJson(routeKey, current));
}
@@ -552,7 +552,7 @@ public class ToolRegistryService implements McpRouteKeyValidator {
"mcp-test",
"/mcp",
null,
new McpProperties.Registry("file:./config/local-core-tools-manifest-sample-v1.json", 300, 5),
new McpProperties.Registry("classpath:/config/local-core-tools-manifest-sample-v1.json", 300, 5),
McpProperties.ToolClient.defaults(),
null,
null,

View File

@@ -85,7 +85,7 @@ public class HttpToolClient implements ToolClient {
@Override
public ToolResponse execute(ToolRequest request, McpRequestContext context) {
try {
log.info(
log.debug(
"TEMP_TOOL_HTTP_REQUEST_BODY direction=mcp_to_tool_server toolName={} version={} endpoint={} body={}",
request.toolName(),
request.version(),
@@ -101,7 +101,7 @@ public class HttpToolClient implements ToolClient {
})
.toEntity(String.class);
JsonNode responseBody = parseResponse(entity.getBody(), entity.getHeaders().getContentType());
log.info(
log.debug(
"TEMP_TOOL_HTTP_RESPONSE_BODY direction=tool_server_to_mcp toolName={} version={} statusCode={} body={}",
request.toolName(),
request.version(),

View File

@@ -11,6 +11,7 @@ import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequest;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequestParser;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcResponse;
import io.shinhanlife.dat.biz.mcp.method.McpMethodHandlerRegistry;
import io.shinhanlife.dat.biz.mcp.observability.TraceLogger;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -32,6 +33,7 @@ import org.springframework.web.bind.annotation.RestController;
* 수정일 수정자 수정내용
* ---------- ---------- ----------------
* 2026.08.06 j.h.w 최초생성
* 2026.09.09 j.h.w notification을 일반 request와 분리해 빈 응답으로 처리
*
* </pre>
*/
@@ -45,6 +47,7 @@ public class McpController {
private final JsonRpcRequestParser requestParser;
private final McpMethodHandlerRegistry handlerRegistry;
private final ObjectMapper objectMapper;
private final TraceLogger traceLogger;
/**
* JSON-RPC 변환과 method dispatch 협력 객체를 주입받습니다. Controller는 실행 규칙을 직접 구현하지 않고 각 책임 객체를 올바른 순서로 연결합니다.
@@ -52,16 +55,21 @@ public class McpController {
* @param requestParser 처리할 요청 정보입니다.
* @param handlerRegistry 협력 객체입니다.
* @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다.
* @param traceLogger notification 처리 결과를 기록할 trace logger입니다.
*/
public McpController(
JsonRpcRequestParser requestParser, McpMethodHandlerRegistry handlerRegistry, ObjectMapper objectMapper) {
JsonRpcRequestParser requestParser,
McpMethodHandlerRegistry handlerRegistry,
ObjectMapper objectMapper,
TraceLogger traceLogger) {
this.requestParser = requestParser;
this.handlerRegistry = handlerRegistry;
this.objectMapper = objectMapper;
this.traceLogger = traceLogger;
}
/**
* 배포별 단일 MCP POST 요청을 받아 JSON-RPC 변환 후 알맞은 handler로 전달합니다. initialize에는 새 correlation header를 발급하고 notification은 HTTP 202, 일반 요청은 HTTP 200으로 응답합니다. mapping의 {@code text/event-stream}은 기존 Agent Builder Accept header를 수용하기 위한 media type일 뿐이며, 이 method는 streaming body를 만들지 않고 항상 단일 JSON 또는 빈 notification 응답을 반환합니다.
* 배포별 단일 MCP POST 요청을 받아 JSON-RPC 변환 후 notification과 일반 request를 먼저 분리합니다. notification은 handler 지원 여부나 처리 오류와 무관하게 HTTP 202 빈 응답을 반환하고, 일반 request만 handler 결과 또는 JSON-RPC 오류를 응답합니다. initialize에는 새 correlation header를 발급합니다. mapping의 {@code text/event-stream}은 기존 Agent Builder Accept header를 수용하기 위한 media type일 뿐이며, 이 method는 streaming body를 만들지 않니다.
*
* @param envelope 처리할 요청 정보입니다.
* @return 처리 결과를 반환합니다.
@@ -73,22 +81,17 @@ public class McpController {
public ResponseEntity<?> handleMcpRequest(@RequestBody JsonNode envelope) {
McpRequestContext context = McpRequestContextHolder.require();
JsonRpcRequest request = requestParser.parse(envelope);
if (request.notification()) {
return handleNotification(request, context);
}
try {
JsonRpcResponse response = handlerRegistry.resolve(request.method()).handle(request, context);
if (request.notification()) {
log.info(
"TEMP_MCP_HTTP_RESPONSE_BODY direction=mcp_to_agent mcpMethod={} httpStatus={} body={}",
request.method(),
202,
"");
return ResponseEntity.accepted().build();
}
ResponseEntity.BodyBuilder responseBuilder =
ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON);
if ("initialize".equals(request.method())) {
responseBuilder.header(MCP_SESSION_ID_HEADER, UUID.randomUUID().toString());
}
log.info(
log.debug(
"TEMP_MCP_HTTP_RESPONSE_BODY direction=mcp_to_agent mcpMethod={} httpStatus={} body={}",
request.method(),
200,
@@ -106,6 +109,44 @@ public class McpController {
}
}
/**
* 파싱이 완료된 JSON-RPC notification을 처리합니다. 등록된 handler가 있으면 부수 효과를 수행하고, handler가 없거나 실행 중 오류가 발생해도 JSON-RPC 응답 body를 만들지 않습니다. 처리 실패는 trace 로그에만 기록하며 호출자에게는 항상 HTTP 202 빈 응답을 반환합니다.
*
* @param request 파싱이 완료된 notification 요청입니다.
* @param context 현재 MCP 요청 context입니다.
* @return HTTP 202와 빈 body를 가진 응답입니다.
*/
private ResponseEntity<Void> handleNotification(
JsonRpcRequest request, McpRequestContext context) {
handlerRegistry
.find(request.method())
.ifPresentOrElse(
handler -> {
try {
handler.handle(request, context);
} catch (RuntimeException exception) {
traceLogger.error(
"mcp_notification_processing_failed",
exception,
"mcpMethod",
request.method());
}
},
() ->
traceLogger.event(
"mcp_notification_ignored",
"mcpMethod",
request.method(),
"reason",
"unsupported"));
log.debug(
"TEMP_MCP_HTTP_RESPONSE_BODY direction=mcp_to_agent mcpMethod={} httpStatus={} body={}",
request.method(),
202,
"");
return ResponseEntity.accepted().build();
}
/**
* 임시 응답 본문 로그를 위해 객체를 JSON 문자열로 직렬화합니다. 직렬화 실패 시에도 실제 응답 흐름은 바꾸지 않고 문자열 변환 결과만 로그에 남깁니다.
*

View File

@@ -3,6 +3,8 @@ package io.shinhanlife.dat.biz.mcp.transport.http;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcResponse;
import io.shinhanlife.dat.biz.mcp.jsonrpc.PublicErrorMapper;
import io.shinhanlife.dat.biz.mcp.jsonrpc.SafeError;
import io.shinhanlife.dat.biz.mcp.observability.TraceLogger;
import java.util.Set;
@@ -28,6 +30,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
* 수정일 수정자 수정내용
* ---------- ---------- ----------------
* 2026.08.06 j.h.w 최초생성
* 2026.09.09 j.h.w 모든 JSON-RPC 및 예상 밖 오류에 공개 오류 변환 적용
*
* </pre>
*/
@@ -46,21 +49,21 @@ public class McpExceptionHandler {
}
/**
* 서버가 의도적으로 발생시킨 JSON-RPC 예외를 HTTP 200의 표준 실패 응답으로 변환합니다.
* 서버가 의도적으로 발생시킨 JSON-RPC 예외를 고정 공개 오류로 정제한 뒤 HTTP 200의 표준 실패 응답으로 변환합니다. 예외의 임의 errorData는 Agent Builder 응답에 사용하지 않습니다.
*
* @param exception 처리 중 발생한 예외 정보입니다.
* @return 처리 결과를 반환합니다.
*/
@ExceptionHandler(JsonRpcException.class)
public ResponseEntity<JsonRpcResponse> handleJsonRpcException(JsonRpcException exception) {
traceLogger.error("error_occurred", exception, "errorCode", exception.errorCode().code());
return ResponseEntity.ok(
JsonRpcResponse.failure(
exception.requestId(), exception.errorCode(), exception.errorData()));
SafeError safeError = PublicErrorMapper.from(exception);
traceLogger.error(
"error_occurred", exception, "errorCode", safeError.protocolCode().code());
return ResponseEntity.ok(JsonRpcResponse.failure(exception.requestId(), safeError));
}
/**
* JSON 문법이 잘못되어 body를 읽지 못한 경우 Parse error 응답을 반환합니다.
* JSON 문법이 잘못되어 body를 읽지 못한 경우 파서 원문을 숨긴 고정 Parse error 응답을 반환합니다.
*
* @param exception 처리 중 발생한 예외 정보입니다.
* @return 처리 결과를 반환합니다.
@@ -71,7 +74,8 @@ public class McpExceptionHandler {
traceLogger.error(
"error_occurred", exception, "errorCode", JsonRpcErrorCode.PARSE_ERROR.code());
return ResponseEntity.ok(
JsonRpcResponse.failure(null, JsonRpcErrorCode.PARSE_ERROR, "Malformed JSON request body"));
JsonRpcResponse.failure(
null, PublicErrorMapper.from(JsonRpcErrorCode.PARSE_ERROR)));
}
/**
@@ -94,7 +98,7 @@ public class McpExceptionHandler {
}
/**
* 예상하지 못한 예외의 내부 내용을 숨기고 안전한 Internal error 응답으로 변환합니다.
* 예상하지 못한 예외의 내부 내용을 로그에만 남기고 최종 fallback인 안전한 Internal error 응답으로 변환합니다.
*
* @param exception 처리 중 발생한 예외 정보입니다.
* @return 처리 결과를 반환합니다.
@@ -104,6 +108,6 @@ public class McpExceptionHandler {
traceLogger.error(
"error_occurred", exception, "errorCode", JsonRpcErrorCode.INTERNAL_ERROR.code());
return ResponseEntity.ok(
JsonRpcResponse.failure(null, JsonRpcErrorCode.INTERNAL_ERROR, "Unexpected server error"));
JsonRpcResponse.failure(null, SafeError.internalError()));
}
}

View File

@@ -9,6 +9,8 @@ import io.shinhanlife.dat.biz.mcp.context.McpRequestContextHolder;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcResponse;
import io.shinhanlife.dat.biz.mcp.jsonrpc.PublicErrorMapper;
import io.shinhanlife.dat.biz.mcp.jsonrpc.SafeError;
import io.shinhanlife.dat.biz.mcp.observability.TraceLogger;
import io.shinhanlife.dat.biz.mcp.transport.http.McpProtocolVersionValidator.ProtocolVersionException;
import jakarta.servlet.Filter;
@@ -41,6 +43,7 @@ import org.springframework.util.StringUtils;
* 수정일 수정자 수정내용
* ---------- ---------- ----------------
* 2026.08.06 j.h.w 최초생성
* 2026.09.09 j.h.w 필터·protocol version 오류 응답에서 내부 원문 제거
*
* </pre>
*/
@@ -162,7 +165,7 @@ public class McpExchangeFilter implements Filter {
request.getRequestURI(),
"mcpMethod",
mcpMethod);
log.info(
log.debug(
"TEMP_MCP_HTTP_REQUEST_BODY direction=agent_to_mcp httpMethod={} path={} mcpMethod={} body={}",
request.getMethod(),
request.getRequestURI(),
@@ -209,8 +212,9 @@ public class McpExchangeFilter implements Filter {
headerOrEmpty(request, "User-Agent"),
"maxBodyBytes",
properties.trace().maxBodyBytes());
writeJsonRpcError(response, JsonRpcErrorCode.INVALID_REQUEST, exception.getMessage());
writeJsonRpcError(response, PublicErrorMapper.from(JsonRpcErrorCode.INVALID_REQUEST));
} catch (JsonRpcException exception) {
SafeError safeError = PublicErrorMapper.from(exception);
traceLogger.error(
"mcp_http_request_rejected",
exception,
@@ -225,8 +229,8 @@ public class McpExchangeFilter implements Filter {
"userAgent",
headerOrEmpty(request, "User-Agent"),
"errorCode",
exception.errorCode().code());
writeJsonRpcError(response, exception.errorCode(), exception.errorData());
safeError.protocolCode().code());
writeJsonRpcError(response, safeError);
} catch (IOException exception) {
// 여기까지 온 IOException은 대개 "쓰려는데 상대가 이미 끊었다"(broken pipe)다.
// Tool은 이미 실행됐을 수 있으므로, 재시도 중복 실행 방지는 Tool Service 책임이다.
@@ -303,18 +307,16 @@ public class McpExchangeFilter implements Filter {
}
/**
* 필터 단계의 JSON-RPC 오류를 현재 외부 계약인 HTTP 200 JSON error envelope로 작성합니다.
* 필터 단계의 안전한 공개 오류를 현재 외부 계약인 HTTP 200 JSON error envelope로 작성합니다. request 검증 예외의 원문은 직렬화하지 않습니다.
*
* @param response 응답에 사용할 객체입니다.
* @param code 입력값입니다.
* @param details 처리할 값입니다.
* @param safeError Agent Builder에 공개 가능한 오류입니다.
*/
private void writeJsonRpcError(
HttpServletResponse response, JsonRpcErrorCode code, Object details) throws IOException {
private void writeJsonRpcError(HttpServletResponse response, SafeError safeError) throws IOException {
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(
response.getOutputStream(), JsonRpcResponse.failure(null, code, details));
response.getOutputStream(), JsonRpcResponse.failure(null, safeError));
}
/**
@@ -360,7 +362,7 @@ public class McpExchangeFilter implements Filter {
}
/**
* protocol version transport 오류 body를 구성합니다. {@code X-Guid}가 요청에 포함된 경우에만 추적값을 body에 싣고, 누락된 경우 MCP가 임의 값을 생성하지 않습니다.
* protocol version transport 오류 body를 고정 공개 문구로 구성합니다. {@code X-Guid}가 요청에 포함된 경우에만 추적값을 body에 싣고, 누락된 경우 MCP가 임의 값을 생성하지 않습니다.
*
* @param context 현재 MCP 요청 context입니다.
* @param exception 처리 중 발생한 예외 정보입니다.
@@ -369,7 +371,7 @@ public class McpExchangeFilter implements Filter {
private Map<String, Object> protocolVersionErrorBody(McpRequestContext context, ProtocolVersionException exception) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("error", "Invalid MCP protocol version");
body.put("message", exception.getMessage());
body.put("message", "MCP protocol version header is missing or unsupported");
body.put("supportedVersions", properties.protocol().supportedVersions());
if (StringUtils.hasText(context.guid())) {
body.put("guid", context.guid());

View File

@@ -1,6 +1,6 @@
mcp:
portal:
enabled: true
registry-url: ${MCP_PORTAL_REGISTRY_URL:file:./config/local-toolserver-info-sample-v1.json}
registry-url: ${MCP_PORTAL_REGISTRY_URL:classpath:/config/local-toolserver-info-sample-v1.json}
local-fixtures:
enabled: ${MCP_LOCAL_FIXTURES_ENABLED:false}

View File

@@ -5,7 +5,7 @@ mcp:
enabled: false
portal:
enabled: true
registry-url: file:./config/local-toolserver-info-sample-v1.json
registry-url: classpath:/config/local-toolserver-info-sample-v1.json
refresh-ttl-seconds: 15
local-fixtures:
enabled: ${MCP_LOCAL_FIXTURES_ENABLED:false}

View File

@@ -42,7 +42,7 @@ management:
mcp:
# Identifies this MCP deployment. Used to namespace the shared Redis cache so that
# several MCP servers can share one Redis without overwriting each other.
identity: ${MCP_IDENTITY:local-mcp}
identity: ${MCP_IDENTITY:ax-hu-mcp}
# 각 컨테이너가 직접 처리하는 공개 MCP path. OpenShift Route는 이 값을 rewrite하지 않는다.
endpoint-path: ${MCP_ENDPOINT_PATH:/mcp}
server:
@@ -55,7 +55,7 @@ mcp:
preferred-version: "2025-11-25"
registry:
# local profile uses this file instead of opening a separate Registry HTTP port.
local-tool-file: ${MCP_LOCAL_TOOL_REGISTRY_FILE:file:./config/local-core-tools-manifest-sample-v1.json}
local-tool-file: ${MCP_LOCAL_TOOL_REGISTRY_FILE:classpath:/config/local-core-tools-manifest-sample-v1.json}
refresh-ttl-seconds: ${MCP_REGISTRY_REFRESH_TTL_SECONDS:300}
tool-client:
connect-timeout-millis: 1000
@@ -95,7 +95,7 @@ mcp:
max-tool-timeout-millis: 30000
portal:
enabled: ${MCP_PORTAL_ENABLED:false}
# HTTP(S) Portal API or local Spring resource location such as file:./config/local-toolserver-info-sample-v1.json.
# HTTP(S) Portal API or local Spring resource location such as classpath:/config/local-toolserver-info-sample-v1.json.
registry-url: ${MCP_PORTAL_REGISTRY_URL:}
refresh-ttl-seconds: ${MCP_PORTAL_REFRESH_TTL_SECONDS:300}
agent-routing-hints:
@@ -105,11 +105,11 @@ mcp:
local-fixtures:
enabled: ${MCP_LOCAL_FIXTURES_ENABLED:false}
manifest-files:
was-cus: file:./config/manifests/was-cus-manifest-sample-v1.json
was-sal: file:./config/manifests/was-sal-manifest-sample-v1.json
was-pro: file:./config/manifests/was-pro-manifest-sample-v1.json
was-sys: file:./config/manifests/was-sys-manifest-sample-v1.json
tool-response-file: file:./config/local-tool-responses-sample-v1.json
was-cus: classpath:/config/manifests/was-cus-manifest-sample-v1.json
was-sal: classpath:/config/manifests/was-sal-manifest-sample-v1.json
was-pro: classpath:/config/manifests/was-pro-manifest-sample-v1.json
was-sys: classpath:/config/manifests/was-sys-manifest-sample-v1.json
tool-response-file: classpath:/config/local-tool-responses-sample-v1.json
# Declared per deployment. baseEndpoint is the execution address and is owned by this file only:
# nothing a Tool Service returns can change where MCP sends the call.
bundles: []

View File

@@ -30,7 +30,7 @@ public final class TestFixtures {
"/mcp",
new McpProperties.Server("shl-axhub-mcp-server", "SHL AX HUB MCP Server", "1.0.0"),
new McpProperties.Registry(
"file:./config/local-core-tools-manifest-sample-v1.json", 30, 5),
"classpath:/config/local-core-tools-manifest-sample-v1.json", 30, 5),
new McpProperties.ToolClient(1_000, 5_000, 300_000, forwardAuthorization, "tool-server-key"),
new McpProperties.Redis(redisEnabled, "test:mcp:tools", "test:mcp:portal-registry"),
new McpProperties.Trace(true, 1_048_576),

View File

@@ -18,6 +18,7 @@ import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequest;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcResponse;
import io.shinhanlife.dat.biz.mcp.jsonrpc.PublicErrorMapper;
import io.shinhanlife.dat.biz.mcp.method.InitializeHandler;
import io.shinhanlife.dat.biz.mcp.method.ToolsCallHandler;
import io.shinhanlife.dat.biz.mcp.method.ToolsListHandler;
@@ -151,7 +152,8 @@ class AgentBuilderContractExampleTest {
requestExample.get("params"),
requestExample.get("id"));
JsonRpcResponse response = new ToolsCallHandler(service).handle(request, context());
JsonRpcResponse response =
new ToolsCallHandler(service, OBJECT_MAPPER).handle(request, context());
JsonNode actual = OBJECT_MAPPER.valueToTree(response);
assertThat(actual).isEqualTo(golden);
@@ -171,7 +173,8 @@ class AgentBuilderContractExampleTest {
OBJECT_MAPPER.readTree("{\"name\":\"customer.search\",\"arguments\":{}}"),
golden.get("id"));
JsonRpcResponse response = new ToolsCallHandler(service).handle(request, context());
JsonRpcResponse response =
new ToolsCallHandler(service, OBJECT_MAPPER).handle(request, context());
// Tool 실행 실패는 최상위 JSON-RPC error가 아니라 isError=true result로 나가야 한다.
assertThat(response.error()).isNull();
@@ -180,8 +183,30 @@ class AgentBuilderContractExampleTest {
}
@Test
void invalidParamsErrorCodeAndMessageMatchThePublishedExample() throws Exception {
void malformedToolsCallParamsMatchThePublishedInvalidParamsExample() throws Exception {
JsonNode golden = example("tools-call-invalid-params-response.json");
ToolExecutionService service = mock(ToolExecutionService.class);
JsonRpcRequest request =
new JsonRpcRequest(
"tools/call",
OBJECT_MAPPER.readTree("{\"name\":\"processing\",\"arguments\":[]}"),
golden.get("id"));
JsonRpcException thrown =
org.junit.jupiter.api.Assertions.assertThrows(
JsonRpcException.class,
() -> new ToolsCallHandler(service, OBJECT_MAPPER).handle(request, context()));
JsonRpcResponse response =
JsonRpcResponse.failure(golden.get("id"), PublicErrorMapper.from(thrown));
JsonNode actual = OBJECT_MAPPER.valueToTree(response);
assertThat(actual).isEqualTo(golden);
}
@Test
void toolInputSchemaMismatchMatchesThePublishedToolErrorExample() throws Exception {
JsonNode golden = example("tools-call-invalid-tool-arguments-response.json");
ToolArgumentValidator validator =
new ToolArgumentValidator(OBJECT_MAPPER, new DefaultJsonSchemaValidator());
ToolCall call = new ToolCall("processing", OBJECT_MAPPER.readTree("{}"));
@@ -198,24 +223,21 @@ class AgentBuilderContractExampleTest {
3_000,
true,
null);
JsonRpcException thrown = null;
try {
validator.validate(call, metadata);
} catch (JsonRpcException exception) {
thrown = exception;
}
assertThat(thrown).isNotNull();
JsonRpcException validationFailure =
org.junit.jupiter.api.Assertions.assertThrows(
JsonRpcException.class, () -> validator.validate(call, metadata));
ToolExecutionService service = mock(ToolExecutionService.class);
when(service.execute(any(), any())).thenThrow(validationFailure);
JsonRpcRequest request =
new JsonRpcRequest(
"tools/call",
OBJECT_MAPPER.readTree("{\"name\":\"processing\",\"arguments\":{}}"),
golden.get("id"));
JsonRpcResponse response =
JsonRpcResponse.failure(golden.get("id"), thrown.errorCode(), thrown.errorData());
new ToolsCallHandler(service, OBJECT_MAPPER).handle(request, context());
JsonNode actual = OBJECT_MAPPER.valueToTree(response);
// 예제는 진단용 `error.data`(traceId/details)를 생략한 축약형이므로 code/message만 대조한다.
assertThat(actual.path("jsonrpc")).isEqualTo(golden.path("jsonrpc"));
assertThat(actual.path("id")).isEqualTo(golden.path("id"));
assertThat(actual.path("error").path("code")).isEqualTo(golden.path("error").path("code"));
assertThat(actual.path("error").path("message"))
.isEqualTo(golden.path("error").path("message"));
assertThat(actual).isEqualTo(golden);
}
}

View File

@@ -0,0 +1,74 @@
package io.shinhanlife.dat.biz.mcp.docs;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
/**
* {@code docs/architecture.md}의 클래스 책임 표가 실제 소스와 어긋나지 않는지 확인하는 문서 계약 테스트입니다. 이 표는 코드 구조를 문서에 복제한 것이라 class를 rename하거나 package를 옮기면 조용히 낡습니다. 실제로 패키지 재구성 한 번에 네
* 개의 이름이 죽은 적이 있어, 사람의 주의력 대신 테스트로 고정합니다. 소스를 읽기만 하며 애플리케이션 context를 띄우지 않습니다.
*/
class ArchitectureDocumentContractTest {
private static final Path ARCHITECTURE = Path.of("docs", "architecture.md");
private static final Path MAIN_PACKAGE =
Path.of("src", "main", "java", "io", "shinhanlife", "dat", "biz", "mcp");
/**
* 표의 첫 두 칸에 백틱으로 감싼 타입 이름과 패키지 경로가 있는 행만 뽑는다.
*/
private static final Pattern TABLE_ROW =
Pattern.compile("^\\| `([A-Z][A-Za-z0-9]*)` \\| `([a-z0-9/]+)` \\|");
/**
* 클래스 표에 적힌 모든 타입이 {@code src/main/java}에 실제로 존재하는지 확인합니다. 존재하지 않는 이름이 있으면 rename 후 문서를 갱신하지 않은 것이므로, 어떤 이름인지 함께 알려 줍니다.
*/
@Test
void everyDocumentedClassPathStillExists() throws IOException {
List<DocumentedType> documented = documentedTypes();
// 표 자체가 사라지면 이 테스트가 조용히 통과해 버리므로 최소 개수를 함께 고정한다.
assertThat(documented)
.withFailMessage("architecture.md의 클래스 책임 표를 찾지 못했습니다. 표 형식이 바뀌었는지 확인하세요.")
.hasSizeGreaterThan(10);
List<DocumentedType> missing = documented.stream().filter(type -> !sourceExists(type)).toList();
assertThat(missing)
.withFailMessage(
"architecture.md에 적힌 package와 class 경로에 소스가 없는 타입: %s%n"
+ "class를 rename하거나 package를 옮겼다면 문서의 표도 같은 변경에서 고쳐야 합니다.",
missing)
.isEmpty();
}
/**
* 클래스 책임 표에서 타입 이름과 패키지 경로를 순서대로 모읍니다.
*/
private List<DocumentedType> documentedTypes() throws IOException {
try (Stream<String> lines = Files.lines(ARCHITECTURE)) {
return lines.map(TABLE_ROW::matcher)
.filter(Matcher::find)
.map(matcher -> new DocumentedType(matcher.group(1), matcher.group(2)))
.distinct()
.toList();
}
}
/**
* 문서에 적힌 패키지와 타입 이름이 가리키는 main 소스 파일이 정확히 존재하는지 확인합니다.
*/
private boolean sourceExists(DocumentedType type) {
return Files.isRegularFile(MAIN_PACKAGE.resolve(type.packagePath()).resolve(type.name() + ".java"));
}
private record DocumentedType(String name, String packagePath) {
}
}

View File

@@ -0,0 +1,215 @@
package io.shinhanlife.dat.biz.mcp.docs;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
/**
* Java 소스의 기계적 서식 규칙을 빌드에서 강제하는 계약 테스트입니다. 이전에는 Spotless Gradle 플러그인이 같은 검사를 했지만, 그 플러그인은 빌드를 읽는 시점에 외부 저장소에서 내려받아야 해서 폐쇄망에서는 검사 하나 때문에 빌드 전체가 시작되지 못합니다. 규칙을
* 여기로 옮겨 외부 의존성 없이 같은 것을 지킵니다.
*
* <p>여기서 보는 것은 <b>도구 없이도 판정할 수 있는 규칙</b>뿐입니다. 들여쓰기 폭과 줄바꿈 위치는 IntelliJ 코드 스타일({@code .idea/codeStyles/Project.xml})이 소유하며 이 테스트가 판정하지 않습니다. 소스를 읽기만 하며
* 애플리케이션 context를 띄우지 않습니다.
*/
class CodeStyleContractTest {
private static final List<Path> SOURCE_ROOTS =
List.of(Path.of("src", "main", "java"), Path.of("src", "test", "java"));
/**
* {@code import a.b.C;}와 {@code import static a.b.C.d;}에서 마지막 이름만 뽑는다.
*/
private static final Pattern IMPORT = Pattern.compile("^import (?:static )?[\\w.]*?(\\w+);");
/**
* 모든 Java 소스가 LF 줄바꿈만 쓰는지 확인합니다. CRLF가 섞이면 Linux 컨테이너에서 문제가 되고, 한 번 섞인 파일은 이후 모든 변경의 diff가 파일 전체로 부풀어 실제 변경을 가립니다.
*/
@Test
void everySourceUsesUnixLineEndings() throws IOException {
List<String> broken = violations(source -> source.raw().contains("\r\n"));
assertThat(broken).withFailMessage("CRLF 줄바꿈이 있는 파일: %s", broken).isEmpty();
}
/**
* 들여쓰기에 탭을 쓰지 않는지 확인합니다. 탭과 공백이 섞이면 보는 도구마다 정렬이 달라집니다.
*/
@Test
void noSourceContainsTabCharacters() throws IOException {
List<String> broken = violations(source -> source.raw().contains("\t"));
assertThat(broken).withFailMessage("탭 문자가 있는 파일: %s", broken).isEmpty();
}
/**
* 줄 끝에 눈에 보이지 않는 공백이 남아 있지 않은지 확인합니다. 화면에 드러나지 않아 사람이 리뷰로 잡을 수 없고, 의미 없는 diff만 만듭니다.
*/
@Test
void noLineEndsWithWhitespace() throws IOException {
List<String> broken =
violations(
source ->
source.lines().stream()
.anyMatch(line -> !line.equals(line.stripTrailing())));
assertThat(broken).withFailMessage("줄 끝에 공백이 있는 파일: %s", broken).isEmpty();
}
/**
* 파일이 개행 하나로 끝나는지 확인합니다. 개행이 없으면 마지막 줄을 고칠 때 diff가 두 줄로 보이고, 여러 개면 의미 없는 빈 줄이 쌓입니다.
*/
@Test
void everySourceEndsWithExactlyOneNewline() throws IOException {
List<String> broken =
violations(source -> !source.raw().endsWith("\n") || source.raw().endsWith("\n\n"));
assertThat(broken).withFailMessage("파일 끝 개행이 정확히 하나가 아닌 파일: %s", broken).isEmpty();
}
/**
* 쓰지 않는 {@code import}가 남아 있지 않은지 확인합니다. 클래스를 옮기거나 지운 뒤 정리하지 않으면 남으며, 실제로는 없는 의존 관계가 있는 것처럼 보이게 합니다.
*
* <p>판정은 그 이름이 import 문 바깥 어디에든 나타나는지로 합니다. Javadoc의 {@code @link}도 사용으로 봅니다. 실제로 쓰는 import를 지우라고 하는 오탐이 없어야 하기 때문입니다.
*/
@Test
void noSourceKeepsAnUnusedImport() throws IOException {
List<String> unused = new ArrayList<>();
for (JavaSource source : sources()) {
String body =
String.join(
"\n",
source.lines().stream().filter(line -> !line.startsWith("import ")).toList());
for (String line : source.lines()) {
Matcher matcher = IMPORT.matcher(line);
if (matcher.find() && !containsWord(body, matcher.group(1))) {
unused.add(source.path() + " -> " + matcher.group(1));
}
}
}
assertThat(unused).withFailMessage("사용하지 않는 import: %s", unused).isEmpty();
}
/**
* {@code import}가 static 먼저, 그다음 알파벳 순으로 놓였는지 확인합니다. 순서가 제각각이면 같은 import를 두 사람이 다른 자리에 넣어 실제 변경과 무관한 diff가 생깁니다.
*
* <p>비교는 <b>세미콜론을 뗀 경로</b>로 합니다. {@code A;}와 {@code A.B;}를 문자열 그대로 비교하면 {@code ';'}(0x3B)가 {@code '.'}(0x2E)보다 커서 중첩 타입이 바깥 타입보다 앞서야 한다고 잘못
* 판정합니다.
*
* <p>그룹 사이 빈 줄은 검사하지 않습니다. 저장소 전체를 세어 보면 빈 줄을 넣은 경계와 넣지 않은 경계가 섞여 있어 지킬 관례가 존재하지 않습니다. 없는 규칙을 만들어 기존 파일을 무더기로 고치는 것보다, 실재하는 규칙만
* 잠그는 편이 낫습니다.
*/
@Test
void importsAreOrderedStaticFirstThenAlphabetically() throws IOException {
List<String> broken = new ArrayList<>();
for (JavaSource source : sources()) {
List<String> statics = new ArrayList<>();
List<String> regular = new ArrayList<>();
for (String line : source.lines()) {
if (line.startsWith("import static ")) {
statics.add(line.substring("import static ".length()).replace(";", ""));
} else if (line.startsWith("import ")) {
regular.add(line.substring("import ".length()).replace(";", ""));
}
}
if (!isSorted(statics) || !isSorted(regular)) {
broken.add(source.path());
}
if (!source.staticImportsComeFirst()) {
broken.add(source.path() + " (static import가 일반 import 뒤에 있음)");
}
}
assertThat(broken).withFailMessage("import 순서가 어긋난 파일: %s", broken).isEmpty();
}
/**
* 검사 대상 소스가 실제로 수집되는지 확인합니다. 경로가 바뀌어 목록이 비면 위 검사들이 모두 조용히 통과하므로 최소 개수를 함께 고정합니다.
*/
@Test
void theSourceSetIsActuallyScanned() throws IOException {
assertThat(sources())
.withFailMessage("Java 소스를 찾지 못했습니다. SOURCE_ROOTS 경로가 바뀌었는지 확인하세요.")
.hasSizeGreaterThan(50);
}
/**
* 규칙을 어긴 파일 경로를 모읍니다. 어떤 파일인지 알려주지 않으면 고칠 수가 없습니다.
*/
private List<String> violations(Predicate<JavaSource> broken) throws IOException {
return sources().stream().filter(broken).map(JavaSource::path).toList();
}
/**
* 목록이 오름차순인지 확인합니다. 정렬본과 비교하면 어긋난 위치를 따로 추적하지 않아도 됩니다.
*/
private boolean isSorted(List<String> values) {
return values.equals(values.stream().sorted().toList());
}
/**
* 이름이 식별자 경계에 맞게 등장하는지 확인합니다. {@code List}를 찾을 때 {@code ArrayList}가 걸리지 않아야 합니다.
*/
private boolean containsWord(String text, String word) {
return Pattern.compile("\\b" + Pattern.quote(word) + "\\b").matcher(text).find();
}
/**
* main과 test의 모든 Java 소스를 읽어 옵니다.
*/
private List<JavaSource> sources() throws IOException {
List<JavaSource> sources = new ArrayList<>();
for (Path root : SOURCE_ROOTS) {
try (Stream<Path> paths = Files.walk(root)) {
for (Path path : paths.filter(path -> path.toString().endsWith(".java")).toList()) {
sources.add(
new JavaSource(
path.toString().replace('\\', '/'),
new String(Files.readAllBytes(path), StandardCharsets.UTF_8)));
}
}
}
return sources;
}
/**
* 검사 대상 소스 하나의 경로와 원본 내용입니다. 줄바꿈 검사 때문에 줄 단위가 아니라 원본 문자열을 그대로 들고 있어야 합니다.
*/
private record JavaSource(String path, String raw) {
/**
* 줄 단위 검사를 위해 개행으로만 나눕니다. CR이 남아 있으면 줄 끝 공백 검사에서도 함께 드러납니다.
*/
List<String> lines() {
return List.of(raw.split("\n", -1));
}
/**
* 마지막 static import가 첫 일반 import보다 앞에 있는지 확인합니다. 둘 중 한쪽이 없으면 판정할 것이 없으므로 참입니다.
*/
boolean staticImportsComeFirst() {
List<String> lines = lines();
int lastStatic = -1;
int firstRegular = Integer.MAX_VALUE;
for (int index = 0; index < lines.size(); index++) {
String line = lines.get(index);
if (line.startsWith("import static ")) {
lastStatic = index;
} else if (line.startsWith("import ") && firstRegular == Integer.MAX_VALUE) {
firstRegular = index;
}
}
return lastStatic < firstRegular;
}
}
}

View File

@@ -0,0 +1,112 @@
package io.shinhanlife.dat.biz.mcp.docs;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
/**
* 패키지 경계를 코드로 고정하는 계약 테스트입니다. MCP는 stdio 등 다른 transport를 가질 수 있는 프로토콜이므로, inbound Servlet 지식이 전송 경계 밖으로 새면 전송 방식이 응용 계층에 굳어져 나중에 떼어낼 수 없게 됩니다. 실제로 재구성 전에는 서블릿
* 타입이 세 패키지에 흩어져 있었고, 문서만으로는 다시 새는 것을 막지 못합니다. 소스 파일을 읽기만 하며 애플리케이션 context를 띄우지 않습니다.
*/
class PackageBoundaryContractTest {
private static final Path MAIN_SOURCES = Path.of("src", "main", "java");
/**
* 전송 경계 안쪽. 이 아래에서만 서블릿 API를 다룰 수 있다.
*/
private static final String TRANSPORT_PACKAGE = "io/shinhanlife/dat/biz/mcp/transport/";
/**
* 서블릿 API를 import하는 production 파일이 {@code transport} 패키지 안에만 있는지 확인합니다. 밖에서 발견되면 어떤 파일인지 함께 알려 주고, 옮기거나 서블릿 타입을 걷어내도록 유도합니다.
*/
@Test
void servletApiStaysInsideTheTransportPackage() throws IOException {
List<Path> leaks = sourcesImporting("jakarta.servlet").stream()
.filter(path -> !normalize(path).contains(TRANSPORT_PACKAGE))
.toList();
assertThat(leaks)
.withFailMessage(
"jakarta.servlet은 transport 패키지 안에서만 사용한다. 경계 밖에서 발견된 파일: %s%n"
+ "HTTP 전용 코드라면 transport/http로 옮기고, 아니라면 서블릿 타입을 파라미터에서 제거하세요.",
leaks)
.isEmpty();
}
/**
* 전송 경계 안쪽 코드가 Tool 실행·Registry 내부로 직접 들어가지 않는지 확인합니다. transport는 요청을 받아 method handler에 넘기는 데까지가 책임이며, 실행 상세는 그 뒤 계층이 소유합니다.
*/
@Test
void transportDoesNotReachIntoExecutionOrRegistry() throws IOException {
List<Path> violations = sourcesImportingAny(List.of(
"io.shinhanlife.dat.biz.mcp.execute.",
"io.shinhanlife.dat.biz.mcp.registry."))
.stream()
.filter(path -> normalize(path).contains(TRANSPORT_PACKAGE))
.toList();
assertThat(violations)
.withFailMessage(
"transport는 execute 또는 registry 계층을 직접 호출하지 않는다. method handler를 거쳐야 한다: %s",
violations)
.isEmpty();
}
/**
* main 소스에서 주어진 import 접두사 중 하나를 사용하는 파일을 모읍니다.
*/
private List<Path> sourcesImportingAny(List<String> importPrefixes) throws IOException {
try (Stream<Path> paths = Files.walk(MAIN_SOURCES)) {
return paths.filter(path -> path.toString().endsWith(".java"))
.filter(path -> declaresAnyImport(path, importPrefixes))
.toList();
}
}
/**
* main 소스에서 주어진 import 접두사를 사용하는 파일을 모읍니다.
*/
private List<Path> sourcesImporting(String importPrefix) throws IOException {
try (Stream<Path> paths = Files.walk(MAIN_SOURCES)) {
return paths.filter(path -> path.toString().endsWith(".java"))
.filter(path -> declaresImport(path, importPrefix))
.toList();
}
}
/**
* 파일이 해당 import 선언을 포함하는지 확인합니다. 주석이나 문자열이 아니라 import 줄만 봅니다.
*/
private boolean declaresImport(Path path, String importPrefix) {
try (Stream<String> lines = Files.lines(path)) {
return lines.anyMatch(line -> line.startsWith("import " + importPrefix));
} catch (IOException exception) {
throw new IllegalStateException("소스를 읽을 수 없습니다: " + path, exception);
}
}
/**
* 파일이 주어진 접두사 중 하나에 해당하는 import 선언을 포함하는지 확인합니다.
*/
private boolean declaresAnyImport(Path path, List<String> importPrefixes) {
try (Stream<String> lines = Files.lines(path)) {
return lines.anyMatch(line -> importPrefixes.stream()
.anyMatch(importPrefix -> line.startsWith("import " + importPrefix)));
} catch (IOException exception) {
throw new IllegalStateException("소스를 읽을 수 없습니다: " + path, exception);
}
}
/**
* OS별 경로 구분자를 슬래시로 통일해 패키지 비교가 Windows에서도 동작하게 합니다.
*/
private String normalize(Path path) {
return path.toString().replace('\\', '/');
}
}

View File

@@ -16,7 +16,7 @@ class ToolArgumentValidatorTest {
new ToolArgumentValidator(OBJECT_MAPPER, new DefaultJsonSchemaValidator());
@Test
void reportsMissingRequiredQueryAsInvalidParams() throws Exception {
void reportsMissingRequiredQueryAsToolArgumentError() throws Exception {
ToolCall call = new ToolCall("document.search", OBJECT_MAPPER.readTree("{}"));
ToolMetadata metadata =
new ToolMetadata(
@@ -36,7 +36,8 @@ class ToolArgumentValidatorTest {
.isInstanceOfSatisfying(
JsonRpcException.class,
exception -> {
assertThat(exception.errorCode()).isEqualTo(JsonRpcErrorCode.INVALID_PARAMS);
assertThat(exception.errorCode())
.isEqualTo(JsonRpcErrorCode.TOOL_ARGUMENT_ERROR);
assertThat(exception.errorData()).isEqualTo("'query' is required");
});
}
@@ -69,9 +70,32 @@ class ToolArgumentValidatorTest {
.isInstanceOfSatisfying(
JsonRpcException.class,
exception -> {
assertThat(exception.errorCode()).isEqualTo(JsonRpcErrorCode.INVALID_PARAMS);
assertThat(exception.errorCode())
.isEqualTo(JsonRpcErrorCode.TOOL_ARGUMENT_ERROR);
assertThat(exception.errorData()).isEqualTo("arguments do not match inputSchema");
assertThat(exception.errorData().toString()).doesNotContain("unexpected");
});
}
@Test
void reportsUnsupportedToolSchemaAsInternalError() throws Exception {
ToolCall call = new ToolCall("document.search", OBJECT_MAPPER.readTree("{}"));
ToolMetadata metadata =
new ToolMetadata(
"document.search",
"1.0.0",
"Search documents",
"http://tool.example/search",
OBJECT_MAPPER.readTree("{\"type\":\"array\"}"),
3_000,
true,
null);
assertThatThrownBy(() -> validator.validate(call, metadata))
.isInstanceOfSatisfying(
JsonRpcException.class,
exception ->
assertThat(exception.errorCode())
.isEqualTo(JsonRpcErrorCode.INTERNAL_ERROR));
}
}

View File

@@ -13,6 +13,8 @@ import static org.mockito.Mockito.when;
import io.shinhanlife.dat.biz.mcp.context.McpRequestContext;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
import io.shinhanlife.dat.biz.mcp.jsonrpc.PublicErrorReason;
import io.shinhanlife.dat.biz.mcp.jsonrpc.SafeError;
import io.shinhanlife.dat.biz.mcp.observability.TraceLogger;
import io.shinhanlife.dat.biz.mcp.registry.ToolMetadata;
import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryService;
@@ -143,8 +145,16 @@ class ToolExecutionServiceTest {
new ToolExecutionService(registry, validator, routing, client, mock(TraceLogger.class));
assertThatThrownBy(() -> service.execute(call, requestContext))
.isInstanceOf(JsonRpcException.class)
.hasMessageContaining("Tool returned HTTP 500");
.isInstanceOfSatisfying(
JsonRpcException.class,
exception -> {
assertThat(exception.errorData()).isInstanceOf(SafeError.class);
SafeError safeError = (SafeError) exception.errorData();
assertThat(safeError.reason())
.isEqualTo(PublicErrorReason.TOOL_EXECUTION_FAILED);
assertThat(exception.getMessage())
.doesNotContain("Tool returned HTTP 500");
});
verify(client).execute(request, requestContext);
verify(registry, never()).refresh(requestContext.routeKey());
@@ -173,8 +183,13 @@ class ToolExecutionServiceTest {
new ToolExecutionService(registry, validator, routing, client, mock(TraceLogger.class));
assertThatThrownBy(() -> service.execute(call, requestContext))
.isInstanceOf(JsonRpcException.class)
.hasMessageContaining("Tool returned HTTP 404");
.isInstanceOfSatisfying(
JsonRpcException.class,
exception -> {
assertThat(exception.errorData()).isInstanceOf(SafeError.class);
assertThat(exception.getMessage())
.doesNotContain("Tool returned HTTP 404");
});
verify(registry).refresh(requestContext.routeKey());
}
@@ -202,8 +217,13 @@ class ToolExecutionServiceTest {
new ToolExecutionService(registry, validator, routing, client, mock(TraceLogger.class));
assertThatThrownBy(() -> service.execute(call, requestContext))
.isInstanceOf(JsonRpcException.class)
.hasMessageContaining("Tool returned HTTP 500");
.isInstanceOfSatisfying(
JsonRpcException.class,
exception -> {
assertThat(exception.errorData()).isInstanceOf(SafeError.class);
assertThat(exception.getMessage())
.doesNotContain("Tool returned HTTP 500");
});
verify(registry, never()).refresh(requestContext.routeKey());
}

View File

@@ -0,0 +1,56 @@
package io.shinhanlife.dat.biz.mcp.jsonrpc;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class PublicErrorMapperTest {
@Test
void discardsArbitraryExceptionData() {
JsonRpcException exception =
new JsonRpcException(
JsonRpcErrorCode.TOOL_EXECUTION_ERROR,
"password=secret http://10.20.30.40:9090 C:\\internal\\secret.json");
SafeError safeError = PublicErrorMapper.from(exception);
assertThat(safeError.protocolCode()).isEqualTo(JsonRpcErrorCode.TOOL_EXECUTION_ERROR);
assertThat(safeError.reason()).isEqualTo(PublicErrorReason.TOOL_EXECUTION_FAILED);
assertThat(safeError.publicData().toString())
.doesNotContain("password", "secret", "10.20.30.40", "internal");
}
@Test
void fallsBackToInternalErrorWhenInputIsUnknown() {
assertThat(PublicErrorMapper.from((JsonRpcException) null))
.isEqualTo(SafeError.internalError());
assertThat(PublicErrorMapper.from((JsonRpcErrorCode) null))
.isEqualTo(SafeError.internalError());
assertThat(new SafeError(null, null)).isEqualTo(SafeError.internalError());
}
@Test
void ignoresSafeErrorWhoseProtocolCodeDoesNotMatchTheException() {
SafeError mismatched =
new SafeError(
JsonRpcErrorCode.TOOL_TIMEOUT,
PublicErrorReason.TOOL_TIMEOUT);
JsonRpcException exception =
new JsonRpcException(
JsonRpcErrorCode.INVALID_REQUEST, mismatched, null, null);
SafeError safeError = PublicErrorMapper.from(exception);
assertThat(safeError.protocolCode()).isEqualTo(JsonRpcErrorCode.INVALID_REQUEST);
assertThat(safeError.reason()).isEqualTo(PublicErrorReason.INVALID_REQUEST);
}
@Test
void mapsToolArgumentErrorToAgentCorrectablePublicReason() {
SafeError safeError = PublicErrorMapper.from(JsonRpcErrorCode.TOOL_ARGUMENT_ERROR);
assertThat(safeError.protocolCode()).isEqualTo(JsonRpcErrorCode.TOOL_ARGUMENT_ERROR);
assertThat(safeError.reason()).isEqualTo(PublicErrorReason.INVALID_TOOL_ARGUMENTS);
}
}

View File

@@ -103,7 +103,8 @@ class InitializeHandlerTest {
InitializeHandler handler = new InitializeHandler(
properties(false, false),
new AgentRoutingHintsProperties(true, "tool-service-manifest"),
registryClient);
registryClient,
io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER);
JsonRpcRequest request = new JsonRpcRequest(
"initialize", JsonNodeFactory.instance.objectNode(), JsonNodeFactory.instance.numberNode(3));
@@ -114,6 +115,36 @@ class InitializeHandlerTest {
assertThat(serialized.path("_meta").path("toolServers"))
.singleElement()
.isEqualTo(routingManifest);
McpSchema.InitializeResult initializeResult = (McpSchema.InitializeResult) response.result();
assertThat((List<?>) initializeResult.meta().get("toolServers"))
.singleElement()
.isInstanceOf(java.util.Map.class);
verify(registryClient).fetchRoutingManifests("external", "/tool-service-manifest");
}
@Test
void returnsBaseInitializeResponseWhenAgentRoutingHintLookupFails() {
ToolRegistryClient registryClient = mock(ToolRegistryClient.class);
when(registryClient.fetchRoutingManifests("external", "/tool-service-manifest"))
.thenThrow(new IllegalStateException("routing manifest unavailable"));
InitializeHandler handler = new InitializeHandler(
properties(false, false),
new AgentRoutingHintsProperties(true, "/tool-service-manifest"),
registryClient,
io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER);
JsonRpcRequest request = new JsonRpcRequest(
"initialize", JsonNodeFactory.instance.objectNode(), JsonNodeFactory.instance.numberNode(4));
var response = handler.handle(request, io.shinhanlife.dat.biz.mcp.TestFixtures.context());
var serialized = io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER.valueToTree(response.result());
assertThat(response.id().asInt()).isEqualTo(4);
assertThat(serialized.path("protocolVersion").asText()).isEqualTo("2025-11-25");
assertThat(serialized.path("capabilities").path("tools").path("listChanged").asBoolean())
.isFalse();
assertThat(serialized.path("serverInfo").path("name").asText())
.isEqualTo("shl-axhub-mcp-server-external");
assertThat(serialized.has("_meta")).isFalse();
verify(registryClient).fetchRoutingManifests("external", "/tool-service-manifest");
}
}

View File

@@ -16,6 +16,8 @@ import io.shinhanlife.dat.biz.mcp.execute.ToolExecutionService;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequest;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcResponse;
import io.shinhanlife.dat.biz.mcp.jsonrpc.PublicErrorReason;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -32,7 +34,7 @@ class ToolsCallHandlerTest {
when(service.execute(any(), any()))
.thenReturn(new ToolExecutionService.Result(OBJECT_MAPPER.readTree("\"Hong\""), 976.1));
var response = new ToolsCallHandler(service).handle(request, context());
var response = new ToolsCallHandler(service, OBJECT_MAPPER).handle(request, context());
ArgumentCaptor<ToolCall> call = ArgumentCaptor.forClass(ToolCall.class);
verify(service).execute(call.capture(), any());
@@ -67,7 +69,7 @@ class ToolsCallHandlerTest {
when(service.execute(any(), any()))
.thenReturn(new ToolExecutionService.Result(OBJECT_MAPPER.readTree(toolResponse), 12.5));
var response = new ToolsCallHandler(service).handle(request, context());
var response = new ToolsCallHandler(service, OBJECT_MAPPER).handle(request, context());
String serialized = OBJECT_MAPPER.writeValueAsString(response);
assertThat(
@@ -94,7 +96,7 @@ class ToolsCallHandlerTest {
new JsonRpcException(
JsonRpcErrorCode.TOOL_TIMEOUT, "customer.search@1.0.0: timed out"));
var response = new ToolsCallHandler(service).handle(request, context());
var response = new ToolsCallHandler(service, OBJECT_MAPPER).handle(request, context());
assertThat(response.error()).isNull();
assertThat(response.result()).isInstanceOf(McpSchema.CallToolResult.class);
@@ -106,7 +108,7 @@ class ToolsCallHandlerTest {
{
"content":[{
"type":"text",
"text":"customer.search@1.0.0: timed out"
"text":"{\\\"reasonCode\\\":\\\"TOOL_TIMEOUT\\\",\\\"message\\\":\\\"Tool execution timed out.\\\"}"
}],
"isError":true
}
@@ -122,7 +124,7 @@ class ToolsCallHandlerTest {
OBJECT_MAPPER.readTree("{\"name\":\"customer.search\",\"arguments\":[]}"),
OBJECT_MAPPER.getNodeFactory().numberNode(3));
ToolsCallHandler handler = new ToolsCallHandler(service);
ToolsCallHandler handler = new ToolsCallHandler(service, OBJECT_MAPPER);
assertThatThrownBy(() -> handler.handle(request, context()))
.isInstanceOfSatisfying(
@@ -143,7 +145,7 @@ class ToolsCallHandlerTest {
OBJECT_MAPPER.readTree("{\"arguments\":{}}"),
OBJECT_MAPPER.getNodeFactory().numberNode(4));
assertThatThrownBy(() -> new ToolsCallHandler(service).handle(request, context()))
assertThatThrownBy(() -> new ToolsCallHandler(service, OBJECT_MAPPER).handle(request, context()))
.isInstanceOfSatisfying(
JsonRpcException.class,
exception -> {
@@ -166,7 +168,7 @@ class ToolsCallHandlerTest {
new JsonRpcException(
JsonRpcErrorCode.INTERNAL_ERROR, "Config-based direct Tool routing is disabled"));
ToolsCallHandler handler = new ToolsCallHandler(service);
ToolsCallHandler handler = new ToolsCallHandler(service, OBJECT_MAPPER);
assertThatThrownBy(() -> handler.handle(request, context()))
.isInstanceOfSatisfying(
@@ -174,4 +176,60 @@ class ToolsCallHandlerTest {
exception ->
assertThat(exception.errorCode()).isEqualTo(JsonRpcErrorCode.INTERNAL_ERROR));
}
@Test
void hidesUnexpectedToolFailureDetailsFromTheAgentResult() throws Exception {
ToolExecutionService service = mock(ToolExecutionService.class);
JsonRpcRequest request =
new JsonRpcRequest(
"tools/call",
OBJECT_MAPPER.readTree("{\"name\":\"customer.search\",\"arguments\":{}}"),
OBJECT_MAPPER.getNodeFactory().numberNode(9));
String internalDetails =
"password=secret endpoint=http://10.20.30.40:9090 C:\\internal\\secret.json";
when(service.execute(any(), any()))
.thenThrow(
new JsonRpcException(
JsonRpcErrorCode.TOOL_EXECUTION_ERROR, internalDetails));
JsonRpcResponse response =
new ToolsCallHandler(service, OBJECT_MAPPER).handle(request, context());
String serialized = OBJECT_MAPPER.writeValueAsString(response);
assertThat(serialized)
.contains(PublicErrorReason.TOOL_EXECUTION_FAILED.reasonCode())
.contains(PublicErrorReason.TOOL_EXECUTION_FAILED.publicMessage())
.doesNotContain("password", "secret", "10.20.30.40", "internal");
JsonNode result = OBJECT_MAPPER.valueToTree(response.result());
assertThat(result.path("isError").asBoolean()).isTrue();
}
@Test
void returnsInputSchemaMismatchAsSafeToolErrorResult() throws Exception {
ToolExecutionService service = mock(ToolExecutionService.class);
JsonRpcRequest request =
new JsonRpcRequest(
"tools/call",
OBJECT_MAPPER.readTree("{\"name\":\"customer.search\",\"arguments\":{}}"),
OBJECT_MAPPER.getNodeFactory().numberNode(10));
when(service.execute(any(), any()))
.thenThrow(
new JsonRpcException(
JsonRpcErrorCode.TOOL_ARGUMENT_ERROR,
"'customerId' is required"));
JsonRpcResponse response =
new ToolsCallHandler(service, OBJECT_MAPPER).handle(request, context());
JsonNode result = OBJECT_MAPPER.valueToTree(response.result());
JsonNode publicError =
OBJECT_MAPPER.readTree(result.path("content").get(0).path("text").asText());
assertThat(response.error()).isNull();
assertThat(result.path("isError").asBoolean()).isTrue();
assertThat(publicError.path("reasonCode").asText())
.isEqualTo("INVALID_TOOL_ARGUMENTS");
assertThat(publicError.path("message").asText())
.isEqualTo("Tool arguments do not match the required schema.");
assertThat(result.toString()).doesNotContain("customerId");
}
}

View File

@@ -157,7 +157,7 @@ class ToolBundleDiscoveryTest {
"http://localhost:18080",
"core.",
true,
"file:./config/local-core-tools-manifest-sample-v1.json"));
"classpath:/config/local-core-tools-manifest-sample-v1.json"));
assertThat(client(properties).fetchTools())
.extracting(ToolMetadata::name)

View File

@@ -57,7 +57,7 @@ class LocalFileToolClientTest {
new LocalFixtureProperties(
true,
Map.of(),
"file:./config/local-tool-responses-sample-v1.json"));
"classpath:/config/local-tool-responses-sample-v1.json"));
}
private McpRequestContext routeContext(String routeKey) {

View File

@@ -1,19 +1,27 @@
package io.shinhanlife.dat.biz.mcp.transport.http;
import static io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER;
import static io.shinhanlife.dat.biz.mcp.TestFixtures.context;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import io.shinhanlife.dat.biz.mcp.context.McpRequestContextHolder;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequest;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequestParser;
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcResponse;
import io.shinhanlife.dat.biz.mcp.method.McpMethodHandlerRegistry;
import io.shinhanlife.dat.biz.mcp.method.McpMethodHandlerRegistry.Handler;
import io.shinhanlife.dat.biz.mcp.observability.TraceLogger;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
@@ -32,25 +40,118 @@ class McpControllerTest {
void acceptsInitializedNotificationWithoutResponseBody() {
JsonRpcRequestParser parser = mock(JsonRpcRequestParser.class);
McpMethodHandlerRegistry registry = mock(McpMethodHandlerRegistry.class);
TraceLogger traceLogger = mock(TraceLogger.class);
Handler handler = mock(Handler.class);
JsonRpcRequest notification =
new JsonRpcRequest(
"notifications/initialized", JsonNodeFactory.instance.objectNode(), null);
when(parser.parse(any())).thenReturn(notification);
when(registry.resolve(notification.method())).thenReturn(handler);
when(registry.find(notification.method())).thenReturn(Optional.of(handler));
McpRequestContextHolder.set(context());
var response =
new McpController(parser, registry, io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER).handleMcpRequest(JsonNodeFactory.instance.objectNode());
new McpController(parser, registry, OBJECT_MAPPER, traceLogger)
.handleMcpRequest(JsonNodeFactory.instance.objectNode());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
assertThat(response.getBody()).isNull();
verify(handler).handle(any(), any());
}
@Test
void acceptsUnsupportedNotificationWithoutResponseBody() {
JsonRpcRequestParser parser = mock(JsonRpcRequestParser.class);
McpMethodHandlerRegistry registry = mock(McpMethodHandlerRegistry.class);
TraceLogger traceLogger = mock(TraceLogger.class);
JsonRpcRequest notification =
new JsonRpcRequest(
"notifications/progress", JsonNodeFactory.instance.objectNode(), null);
when(parser.parse(any())).thenReturn(notification);
when(registry.find(notification.method())).thenReturn(Optional.empty());
McpRequestContextHolder.set(context());
var response =
new McpController(parser, registry, OBJECT_MAPPER, traceLogger)
.handleMcpRequest(JsonNodeFactory.instance.objectNode());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
assertThat(response.getBody()).isNull();
verify(traceLogger)
.event(
"mcp_notification_ignored",
"mcpMethod",
notification.method(),
"reason",
"unsupported");
verify(registry, never()).resolve(any());
}
@Test
void acceptsNotificationWhenHandlerFailsWithoutResponseBody() {
JsonRpcRequestParser parser = mock(JsonRpcRequestParser.class);
McpMethodHandlerRegistry registry = mock(McpMethodHandlerRegistry.class);
TraceLogger traceLogger = mock(TraceLogger.class);
Handler handler = mock(Handler.class);
RuntimeException failure = new IllegalStateException("notification failed");
JsonRpcRequest notification =
new JsonRpcRequest(
"notifications/initialized", JsonNodeFactory.instance.objectNode(), null);
when(parser.parse(any())).thenReturn(notification);
when(registry.find(notification.method())).thenReturn(Optional.of(handler));
when(handler.handle(any(), any())).thenThrow(failure);
McpRequestContextHolder.set(context());
var response =
new McpController(parser, registry, OBJECT_MAPPER, traceLogger)
.handleMcpRequest(JsonNodeFactory.instance.objectNode());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
assertThat(response.getBody()).isNull();
verify(traceLogger)
.error(
"mcp_notification_processing_failed",
failure,
"mcpMethod",
notification.method());
}
@Test
void rejectsUnsupportedRequestWithMethodNotFound() {
JsonRpcRequestParser parser = mock(JsonRpcRequestParser.class);
McpMethodHandlerRegistry registry = mock(McpMethodHandlerRegistry.class);
TraceLogger traceLogger = mock(TraceLogger.class);
JsonRpcRequest request =
new JsonRpcRequest(
"unsupported/request",
JsonNodeFactory.instance.objectNode(),
JsonNodeFactory.instance.numberNode(7));
when(parser.parse(any())).thenReturn(request);
when(registry.resolve(request.method()))
.thenThrow(
new JsonRpcException(
JsonRpcErrorCode.METHOD_NOT_FOUND,
"Unsupported MCP method: " + request.method()));
McpRequestContextHolder.set(context());
assertThatThrownBy(
() ->
new McpController(parser, registry, OBJECT_MAPPER, traceLogger)
.handleMcpRequest(JsonNodeFactory.instance.objectNode()))
.isInstanceOf(JsonRpcException.class)
.satisfies(
exception -> {
JsonRpcException jsonRpcException = (JsonRpcException) exception;
assertThat(jsonRpcException.errorCode())
.isEqualTo(JsonRpcErrorCode.METHOD_NOT_FOUND);
assertThat(jsonRpcException.requestId()).isEqualTo(request.id());
});
}
@Test
void issuesUuidMcpSessionIdForInitializeResponse() {
JsonRpcRequestParser parser = mock(JsonRpcRequestParser.class);
McpMethodHandlerRegistry registry = mock(McpMethodHandlerRegistry.class);
TraceLogger traceLogger = mock(TraceLogger.class);
Handler handler = mock(Handler.class);
JsonRpcRequest initialize =
new JsonRpcRequest(
@@ -64,7 +165,8 @@ class McpControllerTest {
McpRequestContextHolder.set(context());
var response =
new McpController(parser, registry, io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER).handleMcpRequest(JsonNodeFactory.instance.objectNode());
new McpController(parser, registry, OBJECT_MAPPER, traceLogger)
.handleMcpRequest(JsonNodeFactory.instance.objectNode());
String sessionId = response.getHeaders().getFirst(McpController.MCP_SESSION_ID_HEADER);
assertThat(sessionId).isNotBlank();
@@ -76,6 +178,7 @@ class McpControllerTest {
void acceptsEventStreamHeaderButReturnsJson() throws Exception {
JsonRpcRequestParser parser = mock(JsonRpcRequestParser.class);
McpMethodHandlerRegistry registry = mock(McpMethodHandlerRegistry.class);
TraceLogger traceLogger = mock(TraceLogger.class);
Handler handler = mock(Handler.class);
JsonRpcRequest request =
new JsonRpcRequest(
@@ -89,7 +192,8 @@ class McpControllerTest {
McpRequestContextHolder.set(context());
var response =
new McpController(parser, registry, io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER).handleMcpRequest(JsonNodeFactory.instance.objectNode());
new McpController(parser, registry, OBJECT_MAPPER, traceLogger)
.handleMcpRequest(JsonNodeFactory.instance.objectNode());
PostMapping mapping =
McpController.class

View File

@@ -139,7 +139,8 @@ class McpEndpointMethodContractTest {
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.error.code").value(-32600))
.andExpect(jsonPath("$.error.data.details").value("route key is not allowed for fixed endpoint path"));
.andExpect(jsonPath("$.error.data.reasonCode").value("INVALID_REQUEST"))
.andExpect(jsonPath("$.error.data.message").value("Request is invalid."));
}
@Test

View File

@@ -51,10 +51,13 @@ class McpExceptionHandlerTest {
assertThat(entity.getStatusCode().value()).isEqualTo(200);
assertThat(entity.getBody()).isNotNull();
assertThat(entity.getBody().error().code()).isEqualTo(-32602);
assertThat(entity.getBody().error().message())
.isEqualTo("Invalid params: customerNo is required");
assertThat(entity.getBody().error().message()).isEqualTo("Invalid params");
assertThat(entity.getBody().error().data().toString())
.contains("3f2a91c4-6d0e-4b52-9c17-8ae5d2b40f63", "customerNo is required");
.contains(
"3f2a91c4-6d0e-4b52-9c17-8ae5d2b40f63",
"INVALID_PARAMS",
"Request parameters are invalid")
.doesNotContain("customerNo is required");
assertThat(entity.getBody().id().asText()).isEqualTo("req-1");
}
@@ -75,8 +78,24 @@ class McpExceptionHandlerTest {
assertThat(json.path("id").asInt()).isEqualTo(3);
assertThat(json.has("result")).isFalse();
assertThat(json.path("error").path("code").asInt()).isEqualTo(-32602);
assertThat(json.path("error").path("message").asText())
.isEqualTo("Invalid params: 'query' is required");
assertThat(json.path("error").path("message").asText()).isEqualTo("Invalid params");
assertThat(json.path("error").path("data").path("reasonCode").asText())
.isEqualTo("INVALID_PARAMS");
assertThat(json.toString()).doesNotContain("'query' is required");
}
@Test
void hidesUnknownInternalDetailsBehindTheFallbackError() throws Exception {
String internalDetails =
"java.lang.IllegalStateException password=secret http://10.20.30.40:9090/db";
var entity = handler.handleUnexpected(new IllegalStateException(internalDetails));
String serialized = OBJECT_MAPPER.writeValueAsString(entity.getBody());
assertThat(serialized)
.contains("INTERNAL_ERROR", "Unexpected server error")
.doesNotContain(
"IllegalStateException", "password", "secret", "10.20.30.40", "/db");
}
@Test

View File

@@ -443,7 +443,9 @@ class McpExchangeFilterTest {
});
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getContentAsString()).contains("\"code\":-32600", "route key is required");
assertThat(response.getContentAsString())
.contains("\"code\":-32600", "INVALID_REQUEST")
.doesNotContain("route key is required");
}
@Test
@@ -483,7 +485,9 @@ class McpExchangeFilterTest {
});
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getContentAsString()).contains("\"code\":-32600", "route key is not registered");
assertThat(response.getContentAsString())
.contains("\"code\":-32600", "INVALID_REQUEST")
.doesNotContain("route key is not registered");
}
@Test
@@ -522,7 +526,9 @@ class McpExchangeFilterTest {
});
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getContentAsString()).contains("\"code\":-32600", "route key is not registered");
assertThat(response.getContentAsString())
.contains("\"code\":-32600", "INVALID_REQUEST")
.doesNotContain("route key is not registered");
}
@Test