Add configurable MCP payload tracing and external tool domains
All checks were successful
Deploy Gateway / deploy (push) Successful in 1m48s
All checks were successful
Deploy Gateway / deploy (push) Successful in 1m48s
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
{
|
{
|
||||||
"status": "ACTIVE",
|
"status": "ACTIVE",
|
||||||
"serviceKey": "was-cus",
|
"serviceKey": "was-cus",
|
||||||
"serviceDomain": "http://was-cus:8084",
|
"serviceDomain": "https://tool-cus.devjun.net",
|
||||||
"manifestPath": "/tool-manifest",
|
"manifestPath": "/tool-manifest",
|
||||||
"executeBasePath": "/mcp"
|
"executeBasePath": "/mcp"
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
{
|
{
|
||||||
"status": "ACTIVE",
|
"status": "ACTIVE",
|
||||||
"serviceKey": "was-sal",
|
"serviceKey": "was-sal",
|
||||||
"serviceDomain": "http://was-sal:8082",
|
"serviceDomain": "https://tool-sal.devjun.net",
|
||||||
"manifestPath": "/tool-manifest",
|
"manifestPath": "/tool-manifest",
|
||||||
"executeBasePath": "/mcp"
|
"executeBasePath": "/mcp"
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
{
|
{
|
||||||
"status": "ACTIVE",
|
"status": "ACTIVE",
|
||||||
"serviceKey": "was-pro",
|
"serviceKey": "was-pro",
|
||||||
"serviceDomain": "http://was-pro:8085",
|
"serviceDomain": "https://tool-pro.devjun.net",
|
||||||
"manifestPath": "/tool-manifest",
|
"manifestPath": "/tool-manifest",
|
||||||
"executeBasePath": "/mcp"
|
"executeBasePath": "/mcp"
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
{
|
{
|
||||||
"status": "ACTIVE",
|
"status": "ACTIVE",
|
||||||
"serviceKey": "was-sys",
|
"serviceKey": "was-sys",
|
||||||
"serviceDomain": "http://was-sys:8086",
|
"serviceDomain": "https://tool-sys.devjun.net",
|
||||||
"manifestPath": "/tool-manifest",
|
"manifestPath": "/tool-manifest",
|
||||||
"executeBasePath": "/mcp"
|
"executeBasePath": "/mcp"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,3 +223,6 @@ Portal Registry를 사용하는 구성에서는 포털을 route별 Tool Server e
|
|||||||
## Tool list change notification
|
## Tool list change notification
|
||||||
|
|
||||||
`initialize`는 `capabilities.tools.listChanged=true`를 선언한다. 배경 Registry refresh가 기존 route snapshot과 다른 Tool 목록을 성공적으로 확보하면 `ToolListChangedEvent`가 표준 `notifications/tools/list_changed` JSON-RPC notification envelope를 만든다. 현재 HTTP 단발 응답 transport는 notification을 직접 push하지 않으며, SSE/Streamable HTTP 전송 계층이 추가되면 이 이벤트를 route별 Agent 연결에 전달하고 Agent Builder가 `tools/list`를 다시 호출한다.
|
`initialize`는 `capabilities.tools.listChanged=true`를 선언한다. 배경 Registry refresh가 기존 route snapshot과 다른 Tool 목록을 성공적으로 확보하면 `ToolListChangedEvent`가 표준 `notifications/tools/list_changed` JSON-RPC notification envelope를 만든다. 현재 HTTP 단발 응답 transport는 notification을 직접 push하지 않으며, SSE/Streamable HTTP 전송 계층이 추가되면 이 이벤트를 route별 Agent 연결에 전달하고 Agent Builder가 `tools/list`를 다시 호출한다.
|
||||||
|
|
||||||
|
|
||||||
|
- 임시 검증에서 Agent↔MCP와 MCP↔Tool Service payload를 확인해야 하면 `mcp.trace.payload-logging-enabled=true`를 켠다. 이 로그는 JSON 한 줄 형태로 요청·응답 본문을 남기므로 운영 기본값은 false이며, 검증 후 즉시 꺼야 한다.
|
||||||
|
|||||||
@@ -187,9 +187,14 @@ public record McpProperties(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MCP 경계 로그 활성화와 수신 요청 최대 크기 정책 설정입니다.
|
* MCP 경계 로그와 선택적인 payload logging 정책 설정입니다.
|
||||||
|
* payload logging은 Agent·Tool 요청/응답 본문을 남기므로 로컬 검증처럼 명시적으로 켠 환경에서만 사용합니다.
|
||||||
*/
|
*/
|
||||||
public record Trace(boolean enabled, @Min(1) int maxBodyBytes) {
|
public record Trace(
|
||||||
|
boolean enabled,
|
||||||
|
@Min(1) int maxBodyBytes,
|
||||||
|
boolean payloadLoggingEnabled,
|
||||||
|
@Min(1) int maxPayloadBytes) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,34 +1,44 @@
|
|||||||
package io.shinhanlife.dap.biz.mcp.observability;
|
package io.shinhanlife.dap.biz.mcp.observability;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.NullNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.TextNode;
|
||||||
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
|
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
|
||||||
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
|
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
|
||||||
import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder;
|
import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder;
|
||||||
|
|
||||||
import java.util.StringJoiner;
|
import java.util.StringJoiner;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MCP HTTP 입출구와 Tool HTTP 호출 경계의 이벤트를 한 줄 key=value 로그로 남깁니다. 현재 요청의 guid와 requestId는 {@link McpRequestContextHolder}에서 읽어 로그 메시지에 직접 포함하므로 MDC를 사용하지 않습니다.
|
* MCP HTTP 입출구와 Tool HTTP 호출 경계의 이벤트를 로그로 남기는 관측 컴포넌트입니다.
|
||||||
* payload, credential, 사원 식별자({@code employeeNo}·{@code virtualEmployeeNo})는 기록하지 않습니다. 주요 의존성은 로그 활성화 정책을 제공하는 {@link McpProperties}와 SLF4J입니다.
|
* 일반 trace는 key=value 형식으로 유지하고, payload 로그는 명시적으로 켜진 검증 환경에서만 JSON 한 줄로 남깁니다.
|
||||||
|
* 요청 본문과 Tool 응답에는 업무 데이터가 포함될 수 있으므로 기본값은 비활성화이며, 주요 의존성은 trace 설정을 가진 {@link McpProperties}와 JSON 직렬화를 담당하는 {@link ObjectMapper}입니다.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class TraceLogger {
|
public class TraceLogger {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(TraceLogger.class);
|
private static final Logger log = LoggerFactory.getLogger(TraceLogger.class);
|
||||||
|
|
||||||
private final McpProperties properties;
|
private final McpProperties properties;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* trace 로그 활성화 여부를 판단할 설정 객체를 주입받습니다.
|
* trace 설정과 payload JSON 직렬화기를 주입받습니다.
|
||||||
|
* payload logging 활성 여부와 최대 기록 크기는 {@code mcp.trace.*} 설정으로 판단합니다.
|
||||||
*/
|
*/
|
||||||
public TraceLogger(McpProperties properties) {
|
public TraceLogger(McpProperties properties, ObjectMapper objectMapper) {
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 정상적인 처리 단계를 key=value 형식의 구조화 로그로 남깁니다. trace 로그 설정이 켜진 경우에만 기록합니다.
|
* 정상적인 처리 단계를 key=value 형식의 구조화 로그로 남깁니다.
|
||||||
|
* payload는 포함하지 않으며, trace 로그 설정이 켜진 경우에만 기록합니다.
|
||||||
*/
|
*/
|
||||||
public void event(String event, Object... keyValues) {
|
public void event(String event, Object... keyValues) {
|
||||||
if (properties.trace().enabled()) {
|
if (properties.trace().enabled()) {
|
||||||
@@ -43,7 +53,31 @@ public class TraceLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 예외가 발생한 처리 단계를 오류 로그로 남깁니다. 오류 로그에는 예외 종류와 메시지를 함께 남겨 원인 분석을 돕습니다.
|
* Agent와 MCP, MCP와 Tool Server 사이의 요청·응답 payload를 JSON 한 줄 로그로 남깁니다.
|
||||||
|
* 업무 payload 노출 위험 때문에 {@code mcp.trace.payload-logging-enabled=true}인 경우에만 동작하고, 설정된 byte 상한을 넘으면 본문 대신 축약 메시지를 남깁니다.
|
||||||
|
*/
|
||||||
|
public void payload(String event, Object payload, Object... keyValues) {
|
||||||
|
if (!properties.trace().enabled() || !properties.trace().payloadLoggingEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
McpRequestContext context = McpRequestContextHolder.get().orElse(null);
|
||||||
|
ObjectNode envelope = objectMapper.createObjectNode();
|
||||||
|
envelope.put("event", safe(event));
|
||||||
|
envelope.put("guid", context == null ? "" : context.guid());
|
||||||
|
envelope.put("requestId", context == null ? "" : context.requestId());
|
||||||
|
envelope.put("routeKey", context == null ? "" : context.routeKey());
|
||||||
|
addFields(envelope, keyValues);
|
||||||
|
envelope.set("payload", boundedPayload(payload));
|
||||||
|
try {
|
||||||
|
log.info(objectMapper.writeValueAsString(envelope));
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
log.warn("event={} payloadLoggingFailed=true reason={}", safe(event), exception.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 예외가 발생한 처리 단계를 오류 로그로 남깁니다.
|
||||||
|
* 오류 로그에도 payload는 포함하지 않고, 예외 종류와 메시지만 correlation 값과 함께 남깁니다.
|
||||||
*/
|
*/
|
||||||
public void error(String event, Throwable error, Object... keyValues) {
|
public void error(String event, Throwable error, Object... keyValues) {
|
||||||
McpRequestContext context = McpRequestContextHolder.get().orElse(null);
|
McpRequestContext context = McpRequestContextHolder.get().orElse(null);
|
||||||
@@ -59,7 +93,8 @@ public class TraceLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 가변 인자로 받은 키와 값을 두 개씩 묶어 읽기 쉬운 key=value 문자열로 바꿉니다. 홀수 개가 들어오면 짝이 없는 마지막 값은 기록하지 않습니다.
|
* 가변 인자로 받은 key/value 쌍을 사람이 읽기 쉬운 key=value 문자열로 변환합니다.
|
||||||
|
* 홀수 개가 들어오면 짝이 없는 마지막 값은 기록하지 않습니다.
|
||||||
*/
|
*/
|
||||||
private String fields(Object... keyValues) {
|
private String fields(Object... keyValues) {
|
||||||
StringJoiner joiner = new StringJoiner(" ");
|
StringJoiner joiner = new StringJoiner(" ");
|
||||||
@@ -70,7 +105,60 @@ public class TraceLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 줄바꿈과 공백을 치환해 한 로그 이벤트가 여러 줄로 갈라지지 않도록 문자열을 정리합니다.
|
* payload 로그 envelope에 추가 필드를 안전한 JSON 값으로 넣습니다.
|
||||||
|
* 필드명은 로그 검색에 쓰이는 메타데이터만 받는 전제라 개행과 공백을 정리합니다.
|
||||||
|
*/
|
||||||
|
private void addFields(ObjectNode envelope, Object... keyValues) {
|
||||||
|
for (int index = 0; index + 1 < keyValues.length; index += 2) {
|
||||||
|
String key = safe(keyValues[index]);
|
||||||
|
Object value = keyValues[index + 1];
|
||||||
|
if (!key.isBlank()) {
|
||||||
|
envelope.set(key, objectMapper.valueToTree(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* payload를 JSON 노드로 바꾸고 설정된 최대 byte 수를 넘으면 축약 문자열로 대체합니다.
|
||||||
|
* 문자열 payload가 JSON이면 JSON으로 파싱하고, 그렇지 않으면 문자열 그대로 기록합니다.
|
||||||
|
*/
|
||||||
|
private JsonNode boundedPayload(Object payload) {
|
||||||
|
JsonNode node = payloadNode(payload);
|
||||||
|
try {
|
||||||
|
String serialized = objectMapper.writeValueAsString(node);
|
||||||
|
if (serialized.getBytes(java.nio.charset.StandardCharsets.UTF_8).length
|
||||||
|
> properties.trace().maxPayloadBytes()) {
|
||||||
|
return TextNode.valueOf("<payload omitted: exceeds maxPayloadBytes>");
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
return TextNode.valueOf("<payload omitted: serialization failed>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다양한 payload 입력을 JSON 노드로 정규화합니다.
|
||||||
|
* 이미 JsonNode면 그대로 쓰고, 문자열은 JSON 파싱을 먼저 시도한 뒤 실패하면 text node로 보관합니다.
|
||||||
|
*/
|
||||||
|
private JsonNode payloadNode(Object payload) {
|
||||||
|
if (payload == null) {
|
||||||
|
return NullNode.getInstance();
|
||||||
|
}
|
||||||
|
if (payload instanceof JsonNode jsonNode) {
|
||||||
|
return jsonNode;
|
||||||
|
}
|
||||||
|
if (payload instanceof String text) {
|
||||||
|
try {
|
||||||
|
return objectMapper.readTree(text);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return TextNode.valueOf(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return objectMapper.valueToTree(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 줄바꿈과 공백을 치환해 로그 이벤트가 여러 줄로 갈라지지 않게 합니다.
|
||||||
*/
|
*/
|
||||||
private String safe(Object value) {
|
private String safe(Object value) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import com.fasterxml.jackson.databind.node.TextNode;
|
import com.fasterxml.jackson.databind.node.TextNode;
|
||||||
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
|
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
|
||||||
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
|
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
|
||||||
|
import io.shinhanlife.dap.biz.mcp.observability.TraceLogger;
|
||||||
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolClientException;
|
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolClientException;
|
||||||
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest;
|
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest;
|
||||||
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse;
|
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse;
|
||||||
@@ -30,6 +31,7 @@ public class HttpToolClient implements ToolClient {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final McpProperties properties;
|
private final McpProperties properties;
|
||||||
private final HttpClient toolHttpClient;
|
private final HttpClient toolHttpClient;
|
||||||
|
private final TraceLogger traceLogger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON 변환, Tool 설정, 공유 connection pool을 가진 HTTP client를 주입받습니다.
|
* JSON 변환, Tool 설정, 공유 connection pool을 가진 HTTP client를 주입받습니다.
|
||||||
@@ -37,10 +39,12 @@ public class HttpToolClient implements ToolClient {
|
|||||||
public HttpToolClient(
|
public HttpToolClient(
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
McpProperties properties,
|
McpProperties properties,
|
||||||
@Qualifier("toolHttpClient") HttpClient toolHttpClient) {
|
@Qualifier("toolHttpClient") HttpClient toolHttpClient,
|
||||||
|
TraceLogger traceLogger) {
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
this.toolHttpClient = toolHttpClient;
|
this.toolHttpClient = toolHttpClient;
|
||||||
|
this.traceLogger = traceLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,6 +53,17 @@ public class HttpToolClient implements ToolClient {
|
|||||||
@Override
|
@Override
|
||||||
public ToolResponse execute(ToolRequest request, McpRequestContext context) {
|
public ToolResponse execute(ToolRequest request, McpRequestContext context) {
|
||||||
try {
|
try {
|
||||||
|
traceLogger.payload(
|
||||||
|
"tool_http_request_body",
|
||||||
|
request.arguments(),
|
||||||
|
"direction",
|
||||||
|
"mcp_to_tool_server",
|
||||||
|
"toolName",
|
||||||
|
request.toolName(),
|
||||||
|
"version",
|
||||||
|
request.version(),
|
||||||
|
"endpoint",
|
||||||
|
request.endpoint());
|
||||||
RestClient.RequestBodySpec spec = requestSpec(request, context);
|
RestClient.RequestBodySpec spec = requestSpec(request, context);
|
||||||
var entity =
|
var entity =
|
||||||
spec.retrieve()
|
spec.retrieve()
|
||||||
@@ -58,9 +73,19 @@ public class HttpToolClient implements ToolClient {
|
|||||||
throw statusException(response.getStatusCode().value(), request.toolName());
|
throw statusException(response.getStatusCode().value(), request.toolName());
|
||||||
})
|
})
|
||||||
.toEntity(String.class);
|
.toEntity(String.class);
|
||||||
return new ToolResponse(
|
JsonNode responseBody = parseResponse(entity.getBody(), entity.getHeaders().getContentType());
|
||||||
entity.getStatusCode().value(),
|
traceLogger.payload(
|
||||||
parseResponse(entity.getBody(), entity.getHeaders().getContentType()));
|
"tool_http_response_body",
|
||||||
|
responseBody,
|
||||||
|
"direction",
|
||||||
|
"tool_server_to_mcp",
|
||||||
|
"toolName",
|
||||||
|
request.toolName(),
|
||||||
|
"version",
|
||||||
|
request.version(),
|
||||||
|
"statusCode",
|
||||||
|
entity.getStatusCode().value());
|
||||||
|
return new ToolResponse(entity.getStatusCode().value(), responseBody);
|
||||||
} catch (ToolClientException exception) {
|
} catch (ToolClientException exception) {
|
||||||
throw exception;
|
throw exception;
|
||||||
} catch (ResourceAccessException exception) {
|
} catch (ResourceAccessException exception) {
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ final class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
|
|||||||
/**
|
/**
|
||||||
* 요청의 문자 인코딩에 맞는 Reader를 반환합니다. 문자 인코딩이 없으면 JSON의 기본 인코딩인 UTF-8을 사용합니다.
|
* 요청의 문자 인코딩에 맞는 Reader를 반환합니다. 문자 인코딩이 없으면 JSON의 기본 인코딩인 UTF-8을 사용합니다.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 캐시된 요청 본문을 payload 로그용 문자열로 반환합니다.
|
||||||
|
* 이미 메모리에 보관된 byte 배열만 읽으므로 controller가 다시 본문을 읽는 흐름에는 영향을 주지 않습니다.
|
||||||
|
*/
|
||||||
|
String bodyText() {
|
||||||
|
String encoding = getCharacterEncoding();
|
||||||
|
Charset charset = encoding == null ? StandardCharsets.UTF_8 : Charset.forName(encoding);
|
||||||
|
return new String(body, charset);
|
||||||
|
}
|
||||||
@Override
|
@Override
|
||||||
public BufferedReader getReader() {
|
public BufferedReader getReader() {
|
||||||
String encoding = getCharacterEncoding();
|
String encoding = getCharacterEncoding();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
|
|||||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequestParser;
|
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequestParser;
|
||||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
|
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
|
||||||
import io.shinhanlife.dap.biz.mcp.method.McpMethodHandlerRegistry;
|
import io.shinhanlife.dap.biz.mcp.method.McpMethodHandlerRegistry;
|
||||||
|
import io.shinhanlife.dap.biz.mcp.observability.TraceLogger;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -27,14 +28,16 @@ public class McpController {
|
|||||||
|
|
||||||
private final JsonRpcRequestParser requestParser;
|
private final JsonRpcRequestParser requestParser;
|
||||||
private final McpMethodHandlerRegistry handlerRegistry;
|
private final McpMethodHandlerRegistry handlerRegistry;
|
||||||
|
private final TraceLogger traceLogger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON-RPC 변환과 method dispatch 협력 객체를 주입받습니다. Controller는 실행 규칙을 직접 구현하지 않고 각 책임 객체를 올바른 순서로 연결합니다.
|
* JSON-RPC 변환과 method dispatch 협력 객체를 주입받습니다. Controller는 실행 규칙을 직접 구현하지 않고 각 책임 객체를 올바른 순서로 연결합니다.
|
||||||
*/
|
*/
|
||||||
public McpController(
|
public McpController(
|
||||||
JsonRpcRequestParser requestParser, McpMethodHandlerRegistry handlerRegistry) {
|
JsonRpcRequestParser requestParser, McpMethodHandlerRegistry handlerRegistry, TraceLogger traceLogger) {
|
||||||
this.requestParser = requestParser;
|
this.requestParser = requestParser;
|
||||||
this.handlerRegistry = handlerRegistry;
|
this.handlerRegistry = handlerRegistry;
|
||||||
|
this.traceLogger = traceLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,6 +55,15 @@ public class McpController {
|
|||||||
try {
|
try {
|
||||||
JsonRpcResponse response = handlerRegistry.resolve(request.method()).handle(request, context);
|
JsonRpcResponse response = handlerRegistry.resolve(request.method()).handle(request, context);
|
||||||
if (request.notification()) {
|
if (request.notification()) {
|
||||||
|
traceLogger.payload(
|
||||||
|
"mcp_http_response_body",
|
||||||
|
null,
|
||||||
|
"direction",
|
||||||
|
"mcp_to_agent",
|
||||||
|
"mcpMethod",
|
||||||
|
request.method(),
|
||||||
|
"httpStatus",
|
||||||
|
202);
|
||||||
return ResponseEntity.accepted().build();
|
return ResponseEntity.accepted().build();
|
||||||
}
|
}
|
||||||
ResponseEntity.BodyBuilder responseBuilder =
|
ResponseEntity.BodyBuilder responseBuilder =
|
||||||
@@ -59,6 +71,15 @@ public class McpController {
|
|||||||
if ("initialize".equals(request.method())) {
|
if ("initialize".equals(request.method())) {
|
||||||
responseBuilder.header(MCP_SESSION_ID_HEADER, UUID.randomUUID().toString());
|
responseBuilder.header(MCP_SESSION_ID_HEADER, UUID.randomUUID().toString());
|
||||||
}
|
}
|
||||||
|
traceLogger.payload(
|
||||||
|
"mcp_http_response_body",
|
||||||
|
response,
|
||||||
|
"direction",
|
||||||
|
"mcp_to_agent",
|
||||||
|
"mcpMethod",
|
||||||
|
request.method(),
|
||||||
|
"httpStatus",
|
||||||
|
200);
|
||||||
return responseBuilder.body(response);
|
return responseBuilder.body(response);
|
||||||
} catch (JsonRpcException exception) {
|
} catch (JsonRpcException exception) {
|
||||||
if (exception.requestId() != null) {
|
if (exception.requestId() != null) {
|
||||||
|
|||||||
@@ -104,6 +104,17 @@ public class McpExchangeFilter extends OncePerRequestFilter {
|
|||||||
request.getRequestURI(),
|
request.getRequestURI(),
|
||||||
"mcpMethod",
|
"mcpMethod",
|
||||||
mcpMethod);
|
mcpMethod);
|
||||||
|
traceLogger.payload(
|
||||||
|
"mcp_http_request_body",
|
||||||
|
cachedRequest.bodyText(),
|
||||||
|
"direction",
|
||||||
|
"agent_to_mcp",
|
||||||
|
"httpMethod",
|
||||||
|
request.getMethod(),
|
||||||
|
"path",
|
||||||
|
request.getRequestURI(),
|
||||||
|
"mcpMethod",
|
||||||
|
mcpMethod);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
protocolVersionValidator.validatePostInitializeRequest(request, mcpMethod);
|
protocolVersionValidator.validatePostInitializeRequest(request, mcpMethod);
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ mcp:
|
|||||||
bundles: []
|
bundles: []
|
||||||
trace:
|
trace:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
# Request/response body logging is for temporary verification only. Keep disabled by default.
|
||||||
|
payload-logging-enabled: ${MCP_TRACE_PAYLOAD_LOGGING_ENABLED:true}
|
||||||
|
max-payload-bytes: ${MCP_TRACE_MAX_PAYLOAD_BYTES:65536}
|
||||||
# Rejects oversized MCP request bodies before controller processing.
|
# Rejects oversized MCP request bodies before controller processing.
|
||||||
max-body-bytes: 1048576
|
max-body-bytes: 1048576
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public final class TestFixtures {
|
|||||||
"file:./config/local-core-tools-manifest-sample-v1.json", 30, 5),
|
"file:./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.ToolClient(1_000, 5_000, 300_000, forwardAuthorization, "tool-server-key"),
|
||||||
new McpProperties.Redis(redisEnabled, "test:mcp:tools", "test:mcp:portal-registry"),
|
new McpProperties.Redis(redisEnabled, "test:mcp:tools", "test:mcp:portal-registry"),
|
||||||
new McpProperties.Trace(true, 1_048_576),
|
new McpProperties.Trace(true, 1_048_576, false, 65_536),
|
||||||
new McpProperties.Protocol(List.of("2025-11-25"), "2025-11-25"),
|
new McpProperties.Protocol(List.of("2025-11-25"), "2025-11-25"),
|
||||||
new McpProperties.Discovery(!bundles.isEmpty(), 1_000, 3_000, 100, 200, 1_048_576, 30_000),
|
new McpProperties.Discovery(!bundles.isEmpty(), 1_000, 3_000, 100, 200, 1_048_576, 30_000),
|
||||||
new McpProperties.Portal(false, "", "", 300),
|
new McpProperties.Portal(false, "", "", 300),
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class TraceLoggerTest {
|
|||||||
logger.addAppender(appender);
|
logger.addAppender(appender);
|
||||||
McpRequestContextHolder.set(context());
|
McpRequestContextHolder.set(context());
|
||||||
|
|
||||||
new TraceLogger(properties(false, false))
|
new TraceLogger(properties(false, false), io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER)
|
||||||
.event("mcp_http_response_completed", "httpStatus", 200);
|
.event("mcp_http_response_completed", "httpStatus", 200);
|
||||||
|
|
||||||
assertThat(appender.list)
|
assertThat(appender.list)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class HttpToolClientTest {
|
|||||||
.setHeader("Content-Type", "application/json")
|
.setHeader("Content-Type", "application/json")
|
||||||
.setBody("{\"customerName\":\"홍길동\"}"));
|
.setBody("{\"customerName\":\"홍길동\"}"));
|
||||||
HttpToolClient client =
|
HttpToolClient client =
|
||||||
new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient());
|
new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient(), new io.shinhanlife.dap.biz.mcp.observability.TraceLogger(properties(false, false), OBJECT_MAPPER));
|
||||||
ToolRequest request =
|
ToolRequest request =
|
||||||
new ToolRequest(
|
new ToolRequest(
|
||||||
"customer.search",
|
"customer.search",
|
||||||
@@ -75,7 +75,7 @@ class HttpToolClientTest {
|
|||||||
server.enqueue(new MockResponse().setHeader("Content-Type", "application/json")
|
server.enqueue(new MockResponse().setHeader("Content-Type", "application/json")
|
||||||
.setBody("{\"quote\":\"시작이 반이다.\"}"));
|
.setBody("{\"quote\":\"시작이 반이다.\"}"));
|
||||||
HttpToolClient client =
|
HttpToolClient client =
|
||||||
new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient());
|
new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient(), new io.shinhanlife.dap.biz.mcp.observability.TraceLogger(properties(false, false), OBJECT_MAPPER));
|
||||||
ToolRequest request = new ToolRequest(
|
ToolRequest request = new ToolRequest(
|
||||||
"smp_quote_daily", "1.0.0", server.url("/mcp/smp_quote_daily").toString(),
|
"smp_quote_daily", "1.0.0", server.url("/mcp/smp_quote_daily").toString(),
|
||||||
OBJECT_MAPPER.readTree("{\"category\":\"속담\"}"), 3_000);
|
OBJECT_MAPPER.readTree("{\"category\":\"속담\"}"), 3_000);
|
||||||
@@ -97,7 +97,7 @@ class HttpToolClientTest {
|
|||||||
void preservesPlainTextToolResponseAsTextNode() throws Exception {
|
void preservesPlainTextToolResponseAsTextNode() throws Exception {
|
||||||
server.enqueue(new MockResponse().setHeader("Content-Type", "text/plain").setBody("123"));
|
server.enqueue(new MockResponse().setHeader("Content-Type", "text/plain").setBody("123"));
|
||||||
HttpToolClient client =
|
HttpToolClient client =
|
||||||
new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient());
|
new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient(), new io.shinhanlife.dap.biz.mcp.observability.TraceLogger(properties(false, false), OBJECT_MAPPER));
|
||||||
ToolRequest request =
|
ToolRequest request =
|
||||||
new ToolRequest(
|
new ToolRequest(
|
||||||
"processing",
|
"processing",
|
||||||
@@ -116,7 +116,7 @@ class HttpToolClientTest {
|
|||||||
void preservesHttpStatusOnToolError() throws Exception {
|
void preservesHttpStatusOnToolError() throws Exception {
|
||||||
server.enqueue(new MockResponse().setResponseCode(410).setBody("gone"));
|
server.enqueue(new MockResponse().setResponseCode(410).setBody("gone"));
|
||||||
HttpToolClient client =
|
HttpToolClient client =
|
||||||
new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient());
|
new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient(), new io.shinhanlife.dap.biz.mcp.observability.TraceLogger(properties(false, false), OBJECT_MAPPER));
|
||||||
ToolRequest request =
|
ToolRequest request =
|
||||||
new ToolRequest(
|
new ToolRequest(
|
||||||
"customer.search",
|
"customer.search",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class McpControllerTest {
|
|||||||
McpRequestContextHolder.set(context());
|
McpRequestContextHolder.set(context());
|
||||||
|
|
||||||
var response =
|
var response =
|
||||||
new McpController(parser, registry).handleMcpRequest(JsonNodeFactory.instance.objectNode());
|
new McpController(parser, registry, org.mockito.Mockito.mock(io.shinhanlife.dap.biz.mcp.observability.TraceLogger.class)).handleMcpRequest(JsonNodeFactory.instance.objectNode());
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
|
||||||
assertThat(response.getBody()).isNull();
|
assertThat(response.getBody()).isNull();
|
||||||
@@ -64,7 +64,7 @@ class McpControllerTest {
|
|||||||
McpRequestContextHolder.set(context());
|
McpRequestContextHolder.set(context());
|
||||||
|
|
||||||
var response =
|
var response =
|
||||||
new McpController(parser, registry).handleMcpRequest(JsonNodeFactory.instance.objectNode());
|
new McpController(parser, registry, org.mockito.Mockito.mock(io.shinhanlife.dap.biz.mcp.observability.TraceLogger.class)).handleMcpRequest(JsonNodeFactory.instance.objectNode());
|
||||||
|
|
||||||
String sessionId = response.getHeaders().getFirst(McpController.MCP_SESSION_ID_HEADER);
|
String sessionId = response.getHeaders().getFirst(McpController.MCP_SESSION_ID_HEADER);
|
||||||
assertThat(sessionId).isNotBlank();
|
assertThat(sessionId).isNotBlank();
|
||||||
@@ -89,7 +89,7 @@ class McpControllerTest {
|
|||||||
McpRequestContextHolder.set(context());
|
McpRequestContextHolder.set(context());
|
||||||
|
|
||||||
var response =
|
var response =
|
||||||
new McpController(parser, registry).handleMcpRequest(JsonNodeFactory.instance.objectNode());
|
new McpController(parser, registry, org.mockito.Mockito.mock(io.shinhanlife.dap.biz.mcp.observability.TraceLogger.class)).handleMcpRequest(JsonNodeFactory.instance.objectNode());
|
||||||
|
|
||||||
PostMapping mapping =
|
PostMapping mapping =
|
||||||
McpController.class
|
McpController.class
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ class McpExchangeFilterTest {
|
|||||||
base.registry(),
|
base.registry(),
|
||||||
base.toolClient(),
|
base.toolClient(),
|
||||||
base.redis(),
|
base.redis(),
|
||||||
new McpProperties.Trace(true, 8),
|
new McpProperties.Trace(true, 8, false, 65_536),
|
||||||
base.protocol(),
|
base.protocol(),
|
||||||
base.discovery(),
|
base.discovery(),
|
||||||
base.portal(),
|
base.portal(),
|
||||||
@@ -357,7 +357,7 @@ class McpExchangeFilterTest {
|
|||||||
private McpExchangeFilter filter(McpProperties properties) {
|
private McpExchangeFilter filter(McpProperties properties) {
|
||||||
return new McpExchangeFilter(
|
return new McpExchangeFilter(
|
||||||
new McpRequestContextFactory(properties),
|
new McpRequestContextFactory(properties),
|
||||||
new TraceLogger(properties),
|
new TraceLogger(properties, io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER),
|
||||||
OBJECT_MAPPER,
|
OBJECT_MAPPER,
|
||||||
properties,
|
properties,
|
||||||
new McpProtocolVersionValidator(properties));
|
new McpProtocolVersionValidator(properties));
|
||||||
|
|||||||
Reference in New Issue
Block a user