initialize 시 routing hint 직렬화 방식변경
All checks were successful
Deploy Gateway / deploy (push) Successful in 2m38s
All checks were successful
Deploy Gateway / deploy (push) Successful in 2m38s
This commit is contained in:
@@ -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를 진단할 수 있게 한다.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
253
docs/discussions/DISC-20260827-001-request-driven-ttl-refresh.md
Normal file
253
docs/discussions/DISC-20260827-001-request-driven-ttl-refresh.md
Normal 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별 최신성을 일정 수준 유지하는 중간 구현이다.
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 처리할 값입니다.
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 문자열로 직렬화합니다. 직렬화 실패 시에도 실제 응답 흐름은 바꾸지 않고 문자열 변환 결과만 로그에 남깁니다.
|
||||
*
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('\\', '/');
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user