diff --git a/docs/architecture.md b/docs/architecture.md index 6d67bd5..e3c6f72 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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: `을 포함해야 하며, 서버는 매 요청을 독립적으로 검증한다. - header가 누락되거나 지원하지 않는 값이면 JSON-RPC error가 아닌 HTTP `400 Bad Request`를 반환한다. 오류 body는 `error`, `message`, `supportedVersions`, `guid`를 포함해 호출자가 올바른 header를 진단할 수 있게 한다. diff --git a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-invalid-tool-arguments-response.json b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-invalid-tool-arguments-response.json new file mode 100644 index 0000000..e778ee4 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-invalid-tool-arguments-response.json @@ -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 + } +} diff --git a/docs/discussions/DISC-20260827-001-request-driven-ttl-refresh.md b/docs/discussions/DISC-20260827-001-request-driven-ttl-refresh.md new file mode 100644 index 0000000..6bc4015 --- /dev/null +++ b/docs/discussions/DISC-20260827-001-request-driven-ttl-refresh.md @@ -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별 최신성을 일정 수준 유지하는 중간 구현이다. diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/config/McpProperties.java b/src/main/java/io/shinhanlife/dat/biz/mcp/config/McpProperties.java index 0ae40c4..1279dff 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/config/McpProperties.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/config/McpProperties.java @@ -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; diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolArgumentValidator.java b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolArgumentValidator.java index 4d8b75d..f615ba0 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolArgumentValidator.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolArgumentValidator.java @@ -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 오류 분리 * * */ @@ -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); } } diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionService.java b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionService.java index 019a784..66dbce7 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionService.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionService.java @@ -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 원문 대신 공개 오류 분류 전달 * * */ @@ -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); } /** diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcErrorCode.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcErrorCode.java index 55b19a8..ee9e349 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcErrorCode.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcErrorCode.java @@ -14,6 +14,7 @@ import io.modelcontextprotocol.spec.McpSchema; * 수정일 수정자 수정내용 * ---------- ---------- ---------------- * 2026.08.06 j.h.w 최초생성 + * 2026.09.09 j.h.w Tool inputSchema 불일치 내부 분류 추가 * * */ @@ -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; diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcResponse.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcResponse.java index 8a7bcec..1455ef1 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcResponse.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcResponse.java @@ -19,6 +19,7 @@ import java.util.Map; * 수정일 수정자 수정내용 * ---------- ---------- ---------------- * 2026.08.06 j.h.w 최초생성 + * 2026.09.09 j.h.w 공개 오류만 직렬화하도록 SafeError 응답 계약 적용 * * */ @@ -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 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 처리할 값입니다. diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/PublicErrorMapper.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/PublicErrorMapper.java new file mode 100644 index 0000000..bb774f2 --- /dev/null +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/PublicErrorMapper.java @@ -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 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.09.09   j.h.w         최초생성
+ *
+ * 
+ */ +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(); + }; + } +} diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/PublicErrorReason.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/PublicErrorReason.java new file mode 100644 index 0000000..20bdceb --- /dev/null +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/PublicErrorReason.java @@ -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 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.09.09   j.h.w         최초생성
+ *
+ * 
+ */ +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; + } +} diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/SafeError.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/SafeError.java new file mode 100644 index 0000000..9525f67 --- /dev/null +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/SafeError.java @@ -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 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.09.09   j.h.w         최초생성
+ *
+ * 
+ */ +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 publicData() { + Map 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); + } +} diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandler.java b/src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandler.java index c16e9e8..56111c6 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandler.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandler.java @@ -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 값으로 변환 * * */ @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 routingHintMeta(String routeKey) { - if (!routingHintsProperties.enabled() || routeKey == null || registryClient == null) { + if (!routingHintsProperties.enabled() + || routeKey == null + || registryClient == null + || objectMapper == null) { + return Map.of(); + } + try { + List routingManifests = + registryClient.fetchRoutingManifests(routeKey, routingHintsProperties.manifestPath()); + List 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 routingManifests = - registryClient.fetchRoutingManifests(routeKey, routingHintsProperties.manifestPath()); - return Map.of("toolServers", routingManifests); } } diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/method/McpMethodHandlerRegistry.java b/src/main/java/io/shinhanlife/dat/biz/mcp/method/McpMethodHandlerRegistry.java index 08571b6..ce89670 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/method/McpMethodHandlerRegistry.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/method/McpMethodHandlerRegistry.java @@ -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 조회 추가 * * */ @@ -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 find(String method) { + return Optional.ofNullable(handlers.get(method)); } /** diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsCallHandler.java b/src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsCallHandler.java index cbcf116..1943121 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsCallHandler.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsCallHandler.java @@ -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로 변환 * * */ @@ -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; } } diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClient.java index 4ee8cde..f38d850 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClient.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClient.java @@ -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, diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryService.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryService.java index ea4248a..7a84764 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryService.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryService.java @@ -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, diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClient.java index 41d89a0..edbbaf9 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClient.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClient.java @@ -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(), diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpController.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpController.java index 6401689..21a23ee 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpController.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpController.java @@ -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와 분리해 빈 응답으로 처리 * * */ @@ -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 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 문자열로 직렬화합니다. 직렬화 실패 시에도 실제 응답 흐름은 바꾸지 않고 문자열 변환 결과만 로그에 남깁니다. * diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandler.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandler.java index 14e7a3b..c88692a 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandler.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandler.java @@ -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 및 예상 밖 오류에 공개 오류 변환 적용 * * */ @@ -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 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())); } } diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilter.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilter.java index fd9bd9b..33f3965 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilter.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilter.java @@ -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 오류 응답에서 내부 원문 제거 * * */ @@ -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 protocolVersionErrorBody(McpRequestContext context, ProtocolVersionException exception) { Map 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()); diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 658fa3c..5a52468 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -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} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 184734a..8242315 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -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} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b5546c8..20a87f2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: [] diff --git a/config/local-core-tools-manifest-sample-v1.json b/src/main/resources/config/local-core-tools-manifest-sample-v1.json similarity index 100% rename from config/local-core-tools-manifest-sample-v1.json rename to src/main/resources/config/local-core-tools-manifest-sample-v1.json diff --git a/config/local-information-tools-manifest-sample-v1.json b/src/main/resources/config/local-information-tools-manifest-sample-v1.json similarity index 100% rename from config/local-information-tools-manifest-sample-v1.json rename to src/main/resources/config/local-information-tools-manifest-sample-v1.json diff --git a/config/local-process-tools-manifest-sample-v1.json b/src/main/resources/config/local-process-tools-manifest-sample-v1.json similarity index 100% rename from config/local-process-tools-manifest-sample-v1.json rename to src/main/resources/config/local-process-tools-manifest-sample-v1.json diff --git a/config/local-tool-responses-sample-v1.json b/src/main/resources/config/local-tool-responses-sample-v1.json similarity index 100% rename from config/local-tool-responses-sample-v1.json rename to src/main/resources/config/local-tool-responses-sample-v1.json diff --git a/config/local-toolserver-info-sample-v1.json b/src/main/resources/config/local-toolserver-info-sample-v1.json similarity index 100% rename from config/local-toolserver-info-sample-v1.json rename to src/main/resources/config/local-toolserver-info-sample-v1.json diff --git a/config/manifests/was-cus-manifest-sample-v1.json b/src/main/resources/config/manifests/was-cus-manifest-sample-v1.json similarity index 100% rename from config/manifests/was-cus-manifest-sample-v1.json rename to src/main/resources/config/manifests/was-cus-manifest-sample-v1.json diff --git a/config/manifests/was-pro-manifest-sample-v1.json b/src/main/resources/config/manifests/was-pro-manifest-sample-v1.json similarity index 100% rename from config/manifests/was-pro-manifest-sample-v1.json rename to src/main/resources/config/manifests/was-pro-manifest-sample-v1.json diff --git a/config/manifests/was-sal-manifest-sample-v1.json b/src/main/resources/config/manifests/was-sal-manifest-sample-v1.json similarity index 100% rename from config/manifests/was-sal-manifest-sample-v1.json rename to src/main/resources/config/manifests/was-sal-manifest-sample-v1.json diff --git a/config/manifests/was-sys-manifest-sample-v1.json b/src/main/resources/config/manifests/was-sys-manifest-sample-v1.json similarity index 100% rename from config/manifests/was-sys-manifest-sample-v1.json rename to src/main/resources/config/manifests/was-sys-manifest-sample-v1.json diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/TestFixtures.java b/src/test/java/io/shinhanlife/dat/biz/mcp/TestFixtures.java index 7dd72fa..44db5b0 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/TestFixtures.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/TestFixtures.java @@ -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), diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/contract/AgentBuilderContractExampleTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/contract/AgentBuilderContractExampleTest.java index d897931..b102588 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/contract/AgentBuilderContractExampleTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/contract/AgentBuilderContractExampleTest.java @@ -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); } } diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/docs/ArchitectureDocumentContractTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/docs/ArchitectureDocumentContractTest.java new file mode 100644 index 0000000..132f6d4 --- /dev/null +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/docs/ArchitectureDocumentContractTest.java @@ -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 documented = documentedTypes(); + + // 표 자체가 사라지면 이 테스트가 조용히 통과해 버리므로 최소 개수를 함께 고정한다. + assertThat(documented) + .withFailMessage("architecture.md의 클래스 책임 표를 찾지 못했습니다. 표 형식이 바뀌었는지 확인하세요.") + .hasSizeGreaterThan(10); + + List missing = documented.stream().filter(type -> !sourceExists(type)).toList(); + + assertThat(missing) + .withFailMessage( + "architecture.md에 적힌 package와 class 경로에 소스가 없는 타입: %s%n" + + "class를 rename하거나 package를 옮겼다면 문서의 표도 같은 변경에서 고쳐야 합니다.", + missing) + .isEmpty(); + } + + /** + * 클래스 책임 표에서 타입 이름과 패키지 경로를 순서대로 모읍니다. + */ + private List documentedTypes() throws IOException { + try (Stream 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) { + } +} diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/docs/CodeStyleContractTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/docs/CodeStyleContractTest.java new file mode 100644 index 0000000..e448e4c --- /dev/null +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/docs/CodeStyleContractTest.java @@ -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 플러그인이 같은 검사를 했지만, 그 플러그인은 빌드를 읽는 시점에 외부 저장소에서 내려받아야 해서 폐쇄망에서는 검사 하나 때문에 빌드 전체가 시작되지 못합니다. 규칙을 + * 여기로 옮겨 외부 의존성 없이 같은 것을 지킵니다. + * + *

여기서 보는 것은 도구 없이도 판정할 수 있는 규칙뿐입니다. 들여쓰기 폭과 줄바꿈 위치는 IntelliJ 코드 스타일({@code .idea/codeStyles/Project.xml})이 소유하며 이 테스트가 판정하지 않습니다. 소스를 읽기만 하며 + * 애플리케이션 context를 띄우지 않습니다. + */ +class CodeStyleContractTest { + + private static final List 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 broken = violations(source -> source.raw().contains("\r\n")); + + assertThat(broken).withFailMessage("CRLF 줄바꿈이 있는 파일: %s", broken).isEmpty(); + } + + /** + * 들여쓰기에 탭을 쓰지 않는지 확인합니다. 탭과 공백이 섞이면 보는 도구마다 정렬이 달라집니다. + */ + @Test + void noSourceContainsTabCharacters() throws IOException { + List broken = violations(source -> source.raw().contains("\t")); + + assertThat(broken).withFailMessage("탭 문자가 있는 파일: %s", broken).isEmpty(); + } + + /** + * 줄 끝에 눈에 보이지 않는 공백이 남아 있지 않은지 확인합니다. 화면에 드러나지 않아 사람이 리뷰로 잡을 수 없고, 의미 없는 diff만 만듭니다. + */ + @Test + void noLineEndsWithWhitespace() throws IOException { + List 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 broken = + violations(source -> !source.raw().endsWith("\n") || source.raw().endsWith("\n\n")); + + assertThat(broken).withFailMessage("파일 끝 개행이 정확히 하나가 아닌 파일: %s", broken).isEmpty(); + } + + /** + * 쓰지 않는 {@code import}가 남아 있지 않은지 확인합니다. 클래스를 옮기거나 지운 뒤 정리하지 않으면 남으며, 실제로는 없는 의존 관계가 있는 것처럼 보이게 합니다. + * + *

판정은 그 이름이 import 문 바깥 어디에든 나타나는지로 합니다. Javadoc의 {@code @link}도 사용으로 봅니다. 실제로 쓰는 import를 지우라고 하는 오탐이 없어야 하기 때문입니다. + */ + @Test + void noSourceKeepsAnUnusedImport() throws IOException { + List 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가 생깁니다. + * + *

비교는 세미콜론을 뗀 경로로 합니다. {@code A;}와 {@code A.B;}를 문자열 그대로 비교하면 {@code ';'}(0x3B)가 {@code '.'}(0x2E)보다 커서 중첩 타입이 바깥 타입보다 앞서야 한다고 잘못 + * 판정합니다. + * + *

그룹 사이 빈 줄은 검사하지 않습니다. 저장소 전체를 세어 보면 빈 줄을 넣은 경계와 넣지 않은 경계가 섞여 있어 지킬 관례가 존재하지 않습니다. 없는 규칙을 만들어 기존 파일을 무더기로 고치는 것보다, 실재하는 규칙만 + * 잠그는 편이 낫습니다. + */ + @Test + void importsAreOrderedStaticFirstThenAlphabetically() throws IOException { + List broken = new ArrayList<>(); + for (JavaSource source : sources()) { + List statics = new ArrayList<>(); + List 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 violations(Predicate broken) throws IOException { + return sources().stream().filter(broken).map(JavaSource::path).toList(); + } + + /** + * 목록이 오름차순인지 확인합니다. 정렬본과 비교하면 어긋난 위치를 따로 추적하지 않아도 됩니다. + */ + private boolean isSorted(List 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 sources() throws IOException { + List sources = new ArrayList<>(); + for (Path root : SOURCE_ROOTS) { + try (Stream 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 lines() { + return List.of(raw.split("\n", -1)); + } + + /** + * 마지막 static import가 첫 일반 import보다 앞에 있는지 확인합니다. 둘 중 한쪽이 없으면 판정할 것이 없으므로 참입니다. + */ + boolean staticImportsComeFirst() { + List 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; + } + } +} diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/docs/PackageBoundaryContractTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/docs/PackageBoundaryContractTest.java new file mode 100644 index 0000000..115b874 --- /dev/null +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/docs/PackageBoundaryContractTest.java @@ -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 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 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 sourcesImportingAny(List importPrefixes) throws IOException { + try (Stream paths = Files.walk(MAIN_SOURCES)) { + return paths.filter(path -> path.toString().endsWith(".java")) + .filter(path -> declaresAnyImport(path, importPrefixes)) + .toList(); + } + } + + /** + * main 소스에서 주어진 import 접두사를 사용하는 파일을 모읍니다. + */ + private List sourcesImporting(String importPrefix) throws IOException { + try (Stream 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 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 importPrefixes) { + try (Stream 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('\\', '/'); + } +} diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolArgumentValidatorTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolArgumentValidatorTest.java index 4282a78..980f0db 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolArgumentValidatorTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolArgumentValidatorTest.java @@ -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)); + } } diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionServiceTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionServiceTest.java index 9880c3b..95248d9 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionServiceTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionServiceTest.java @@ -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()); } diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/jsonrpc/PublicErrorMapperTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/jsonrpc/PublicErrorMapperTest.java new file mode 100644 index 0000000..ff55c0a --- /dev/null +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/jsonrpc/PublicErrorMapperTest.java @@ -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); + } +} diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandlerTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandlerTest.java index a98d510..852c43f 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandlerTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandlerTest.java @@ -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"); } } diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/method/ToolsCallHandlerTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/method/ToolsCallHandlerTest.java index ebdb6fc..8d449ee 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/method/ToolsCallHandlerTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/method/ToolsCallHandlerTest.java @@ -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 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"); + } } diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscoveryTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscoveryTest.java index 938f085..f60caf3 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscoveryTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscoveryTest.java @@ -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) diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/toolclient/LocalFileToolClientTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/toolclient/LocalFileToolClientTest.java index 053f08d..78da37f 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/toolclient/LocalFileToolClientTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/toolclient/LocalFileToolClientTest.java @@ -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) { diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpControllerTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpControllerTest.java index 75cef75..7eaf55a 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpControllerTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpControllerTest.java @@ -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 diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpEndpointMethodContractTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpEndpointMethodContractTest.java index cbac132..b8f0413 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpEndpointMethodContractTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpEndpointMethodContractTest.java @@ -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 diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandlerTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandlerTest.java index 8bf77d8..6e98d57 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandlerTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandlerTest.java @@ -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 diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilterTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilterTest.java index d34680b..f60bac3 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilterTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilterTest.java @@ -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