Skip to content

Commit 9cf27ec

Browse files
authored
Merge pull request #290 from DocGrid/hotfix/comment-cleanup
docs: MCP 도메인 주석/Javadoc 정리
2 parents 679ae0d + f84e768 commit 9cf27ec

13 files changed

Lines changed: 94 additions & 103 deletions

backend/src/main/java/com/opensource/docgrid/domain/mcp/converter/McpAccessTokenConverter.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,14 @@
1010
import com.opensource.docgrid.domain.mcp.dto.response.McpAccessTokenRevokeResponse;
1111
import com.opensource.docgrid.domain.user.entity.McpAccessToken;
1212

13+
/**
14+
* McpAccessToken Entity를 API 응답 DTO로 변환한다. 토큰 원본 값은 발급 직후 1회 응답에만
15+
* 담기고 Entity에는 해시만 저장되므로, 그 값은 Entity가 아니라 파라미터로 별도로 받는다.
16+
*/
1317
@Component
1418
public class McpAccessTokenConverter {
1519

20+
/** 발급 응답을 만든다. rawToken은 Entity에 저장되지 않아 이 응답 이후로는 다시 조회할 방법이 없다. */
1621
public McpAccessTokenIssueResponse toIssueResponse(McpAccessToken token, String rawToken) {
1722
return new McpAccessTokenIssueResponse(
1823
token.getId(),
@@ -22,6 +27,7 @@ public McpAccessTokenIssueResponse toIssueResponse(McpAccessToken token, String
2227
);
2328
}
2429

30+
/** 단건 조회 응답을 만든다. Entity의 tokenHash는 옮기지 않아 원본·해시 둘 다 응답에 노출되지 않는다. */
2531
public McpAccessTokenResponse toResponse(McpAccessToken token) {
2632
return new McpAccessTokenResponse(
2733
token.getId(),
@@ -31,10 +37,12 @@ public McpAccessTokenResponse toResponse(McpAccessToken token) {
3137
);
3238
}
3339

40+
/** 목록 응답을 만든다. 필드 매핑 규칙이 두 곳에서 어긋나지 않도록 toResponse()를 그대로 재사용한다. */
3441
public McpAccessTokenListResponse toListResponse(List<McpAccessToken> tokens) {
3542
return new McpAccessTokenListResponse(tokens.stream().map(this::toResponse).toList());
3643
}
3744

45+
/** 폐기 응답을 만든다. revoke() 처리 직후의 Entity 상태를 그대로 읽으므로 별도 재조회가 없다. */
3846
public McpAccessTokenRevokeResponse toRevokeResponse(McpAccessToken token) {
3947
return new McpAccessTokenRevokeResponse(token.getId(), token.getRevokedAt());
4048
}

backend/src/main/java/com/opensource/docgrid/domain/mcp/dto/response/DocumentDetailResponse.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import io.swagger.v3.oas.annotations.media.Schema;
88

9+
@Schema(description = "MCP 문서 상세 조회 응답")
910
public record DocumentDetailResponse(
1011
@Schema(description = "문서 ID") Long documentId,
1112
@Schema(description = "문서 제목") String title,

backend/src/main/java/com/opensource/docgrid/domain/mcp/dto/response/McpAccessTokenIssueResponse.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import io.swagger.v3.oas.annotations.media.Schema;
66

7-
// MCP 토큰 발급 응답 DTO
7+
@Schema(description = "MCP 토큰 발급 응답")
88
public record McpAccessTokenIssueResponse(
99
@Schema(description = "토큰 ID") Long tokenId,
1010
@Schema(description = "토큰 원본 값 - 이 응답에서만 1회 노출됨") String token,

backend/src/main/java/com/opensource/docgrid/domain/mcp/dto/response/McpAccessTokenListResponse.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import io.swagger.v3.oas.annotations.media.Schema;
66

7-
// MCP 토큰 목록 조회 응답 DTO
7+
@Schema(description = "MCP 토큰 목록 조회 응답")
88
public record McpAccessTokenListResponse(
99
@Schema(description = "내 MCP 토큰 목록") List<McpAccessTokenResponse> tokens
1010
) {

backend/src/main/java/com/opensource/docgrid/domain/mcp/dto/response/McpAccessTokenResponse.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import io.swagger.v3.oas.annotations.media.Schema;
66

7-
// MCP 토큰 조회 응답 DTO
7+
@Schema(description = "MCP 토큰 정보")
88
public record McpAccessTokenResponse(
99
@Schema(description = "토큰 ID") Long tokenId,
1010
@Schema(description = "발급 시각") LocalDateTime createdAt,

backend/src/main/java/com/opensource/docgrid/domain/mcp/dto/response/McpAccessTokenRevokeResponse.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import io.swagger.v3.oas.annotations.media.Schema;
66

7-
// MCP 토큰 폐기 응답 DTO
7+
@Schema(description = "MCP 토큰 폐기 응답")
88
public record McpAccessTokenRevokeResponse(
99
@Schema(description = "토큰 ID") Long tokenId,
1010
@Schema(description = "폐기 시각") LocalDateTime revokedAt

backend/src/main/java/com/opensource/docgrid/domain/mcp/security/McpApiKeyAuthFilter.java

Lines changed: 14 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -20,34 +20,21 @@
2020
import jakarta.servlet.http.HttpServletResponse;
2121
import lombok.RequiredArgsConstructor;
2222

23-
/*
24-
* Claude Desktop 같은 MCP 클라이언트가 /mcp 경로로 도구 호출 요청을 보낼 때,
25-
* 헤더에 담긴 API 키(=우리가 발급한 MCP 토큰)가 유효한지 검증하는 인증 필터.
23+
/**
24+
* {@code /mcp}로 오는 MCP 프로토콜 요청을 API 키(장기 MCP 토큰)로 인증하는 필터.
2625
*
27-
* 웹사이트 로그인은 JWT를 쓰지만, JWT는 만료 시간이 짧아(1시간) Claude Desktop처럼
28-
* 설정 파일에 한 번 등록해두고 계속 재사용하는 시나리오엔 맞지 않는다.
29-
* 그래서 별도의 장기 API 키 방식을 도입했고, 이 필터가 그 검증을 담당한다.
30-
*
31-
* 처리 흐름:
32-
* 1) Authorization 헤더에서 API 키(원본 토큰 문자열)를 꺼낸다.
33-
* 2) 그 키를 해시화해서 DB(mcp_access_tokens)와 대조해 유효성을 확인한다.
34-
* 3) 유효하면 그 토큰의 소유자(userId)를 알아내
35-
* Spring Security의 SecurityContext에 "이 요청은 이 유저 것"이라고 등록한다.
36-
* 4) 이후 요청을 처리하는 모든 코드(도구 핸들러 등)가
37-
* SecurityContext에서 이 userId를 꺼내 "누가 요청했는지" 알 수 있게 된다.
38-
*
39-
* OncePerRequestFilter를 상속해 요청 1개당 정확히 한 번만 실행되도록 보장하며,
40-
* shouldNotFilter()로 /mcp 경로에만 좁게 적용되도록 스코프를 제한한다
41-
* (다른 경로, 예: /mcp/tokens는 기존 JwtAuthenticationFilter가 별도로 담당).
26+
* <p>웹 로그인은 JWT를 쓰지만 JWT는 만료 시간이 짧아(1시간) Claude Desktop처럼 설정 파일에
27+
* 한 번 등록해두고 계속 재사용하는 시나리오엔 맞지 않는다. 그래서 별도의 장기 API 키 방식을
28+
* 도입했고, 이 필터가 그 검증을 담당한다. {@link #shouldNotFilter}로 {@code /mcp} 경로에만
29+
* 좁게 적용되며, {@code /mcp/tokens} 등 다른 경로는
30+
* {@link com.opensource.docgrid.domain.auth.jwt.JwtAuthenticationFilter}가 별도로 담당한다.
4231
*/
4332
@RequiredArgsConstructor
4433
public class McpApiKeyAuthFilter extends OncePerRequestFilter {
4534

46-
// 이 필터가 감시할 유일한 경로. /mcp/tokens 같은 다른 경로는 이 필터와 무관하다.
4735
// WebMvcConfig의 OSIV 제외 경로와 동일한 값을 참조해야 하므로 public으로 공개한다.
4836
public static final String MCP_ENDPOINT = "/mcp";
4937

50-
// 실제 토큰 검증 로직(해시 대조, DB 조회)은 여기에 위임한다 — 필터는 인증 "흐름"만 담당.
5138
private final McpAccessTokenCommandService mcpAccessTokenCommandService;
5239

5340
// MCP Streamable HTTP는 응답을 비동기 재디스패치로 처리한다. SecurityContextHolder에만
@@ -56,53 +43,36 @@ public class McpApiKeyAuthFilter extends OncePerRequestFilter {
5643
// 명시적으로 저장해 재디스패치에서도 같은 인증 정보를 복원할 수 있게 한다.
5744
private final SecurityContextRepository securityContextRepository = new RequestAttributeSecurityContextRepository();
5845

59-
// true를 반환하면 이 필터를 건너뛴다. 즉 "/mcp가 아닌 요청은 이 필터를 타지 마라"는 뜻.
6046
@Override
6147
protected boolean shouldNotFilter(HttpServletRequest request) {
6248
return !MCP_ENDPOINT.equals(request.getRequestURI());
6349
}
6450

65-
// 실제 인증 로직. shouldNotFilter가 false를 반환한 요청(=/mcp 요청)에서만 실행된다.
6651
@Override
6752
protected void doFilterInternal(HttpServletRequest request,
6853
HttpServletResponse response,
6954
FilterChain filterChain) throws ServletException, IOException {
7055

71-
// Authorization 헤더에서 "Bearer " 뒤에 붙은 실제 토큰 값만 추출
72-
String token = resolveToken(request);
73-
74-
// 헤더에 토큰이 아예 없으면 인증 시도 자체를 스킵 (아래로 그냥 통과됨)
56+
String token = resolveToken(request); // 토큰 추출
7557
if (StringUtils.hasText(token)) {
76-
77-
// 토큰을 해시화해서 DB(mcp_access_tokens)와 대조 → 유효하면 userId를 담은 Optional 반환
7858
Optional<Long> userId = mcpAccessTokenCommandService.authenticate(token);
79-
80-
// Optional이 값을 갖고 있을 때(=토큰이 유효할 때)만 아래 블록 실행
8159
userId.ifPresent(id -> {
82-
83-
// "인증 성공했다"는 사실을 표현하는 Spring Security 객체를 생성.
84-
// principal 자리엔 실제 이름 대신 "mcp-client"라는 고정 문자열만 넣음
85-
// (JWT 필터처럼 principal에 userId를 바로 넣는 방식과는 다른 패턴이니 주의)
60+
// principal에는 JWT 필터처럼 userId를 바로 넣지 않고 고정 문자열("mcp-client")만
61+
// 넣는다 — API 키엔 email 같은 신원 표시값이 없어서다. 진짜 userId는 details에
62+
// 저장하므로, 도구 핸들러에서 사용자를 식별할 땐 getPrincipal()이 아니라
63+
// getDetails()를 써야 한다.
8664
UsernamePasswordAuthenticationToken authentication =
8765
new UsernamePasswordAuthenticationToken("mcp-client", null, List.of());
88-
89-
// 진짜 userId는 details 필드에 별도로 저장해둔다.
90-
// 나중에 도구 핸들러에서 유저를 식별하려면 getPrincipal()이 아니라 getDetails()를 써야 함
9166
authentication.setDetails(id);
9267

93-
// 이 요청이 처리되는 동안 전역적으로 접근 가능한 컨텍스트에 인증 정보를 저장
9468
SecurityContext context = SecurityContextHolder.getContext();
9569
context.setAuthentication(authentication);
96-
// 비동기 재디스패치에서도 복원되도록 요청 attribute에 명시적으로 저장
9770
securityContextRepository.saveContext(context, request, response);
9871
});
9972
}
10073

101-
// 인증 성공/실패 여부와 무관하게 항상 다음 필터로 요청을 넘긴다.
102-
// 인증 실패(SecurityContext가 비어있음)에 대한 최종 차단은 이 필터가 아니라
103-
// SecurityConfig의 anyRequest().authenticated()가 처리한다. 커스텀 AuthenticationEntryPoint가
104-
// 없어 Spring Security 기본 동작(Http403ForbiddenEntryPoint)에 따라 403으로 응답한다 —
105-
// 이는 이 필터만의 동작이 아니라 앱 전체 미인증 요청에 이미 적용되는 기존 동작이다.
74+
// 인증 실패(SecurityContext가 비어있음)의 최종 차단은 이 필터가 아니라 SecurityConfig의
75+
// anyRequest().authenticated() + RestAuthenticationEntryPoint가 401로 응답한다.
10676
filterChain.doFilter(request, response);
10777
}
10878

backend/src/main/java/com/opensource/docgrid/domain/mcp/security/McpRateLimiter.java

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -52,36 +52,32 @@ public McpRateLimiter() {
5252
/**
5353
* 사용자·도구 단위 호출 제한을 검사한다. 제한을 초과하면 DocGridException을 던진다.
5454
*
55+
* <p>구간 만료 판단·리셋·카운트 증가를 {@code synchronized(window)} 하나로 묶는 이유(과거
56+
* 레이스 컨디션 이력)는 아래 {@code synchronized} 블록 위 주석 참고.
57+
*
5558
* @param userId 사용자 ID
5659
* @param toolName 도구 이름
5760
* @param limitPerMinute 분당 호출 제한 횟수
5861
*/
5962
public void checkLimit(Long userId, String toolName, int limitPerMinute) {
60-
// "5:search_documents"처럼 사용자+도구를 하나로 묶은 식별자
6163
String key = userId + ":" + toolName;
6264
long now = System.currentTimeMillis();
63-
// 이 조합을 처음 보는 거면 지금 시각으로 새 Window를 만들고, 이미 있으면 기존 것을 가져온다
6465
Window window = windows.computeIfAbsent(key, k -> new Window(now));
6566

66-
// 리셋 여부 판단과 카운터 증가를 같은 동기화 구역에 묶어야 한다 — 분리하면 "리셋 직전에
67-
// 만료 전 카운터로 증가해버리는" 레이스가 생겨 새 윈도우의 첫 요청이 부당하게 막힐 수 있다.
68-
//
69-
// (과거에는 windowStartMillis/count를 AtomicLong/AtomicInteger로 따로 관리해서
70-
// "만료 판단+시작시각 갱신"과 "카운트 리셋"이 원자적으로 묶여있지 않았다. 그 틈에
71-
// 다른 스레드가 끼어들면 "시작시각은 이미 새 걸로 바뀌었는데 카운트는 옛날 값 그대로"인
72-
// 상태를 보게 되어, 새 윈도우의 첫 요청이 부당하게 차단되거나 카운트가 유실되는
73-
// 레이스 컨디션이 있었다. 지금처럼 synchronized(window) 블록 하나로 전체를 묶으면
74-
// 이 틈 자체가 사라진다.)
67+
// 만료 판단·리셋·카운트 증가를 synchronized(window) 하나로 묶어야 하는 이유 — 과거엔
68+
// windowStartMillis/count를 AtomicLong/AtomicInteger로 따로 관리해서 레이스가 있었다.
69+
// 예: 20/20 다 쓴 직후, 윈도우가 막 만료된 순간에 두 요청(21·22번째)이 겹치면:
70+
// 1) 스레드A(21번째)가 만료를 감지해 windowStart만 새 시각으로 갱신 — count=0은 아직 실행 전
71+
// 2) 그 틈에 스레드B(22번째)가 들어와 "안 만료됨"으로 오판(리셋 스킵) → 옛 count(20)에 증가
72+
// → 21 > 20 → 새 윈도우의 첫 요청인데 부당하게 차단됨
73+
// 3) 뒤늦게 스레드A가 count=0 실행 → 스레드B가 방금 남긴 증가(21)까지 통째로 사라짐
74+
// synchronized(window)로 판단+리셋+증가를 한 덩어리로 묶으면 이 틈 자체가 사라진다.
7575
synchronized (window) {
76-
// 1. 윈도우가 만료됐으면(=시작된 지 60초 지났으면) 리셋
77-
// → 새 구간 시작: 시작 시각을 지금으로, 카운트를 0으로
7876
if (now - window.windowStartMillis >= windowMillis) {
7977
window.windowStartMillis = now;
8078
window.count = 0;
8179
}
82-
// 2. 이번 호출을 카운트에 반영 (리셋됐으면 0→1, 아니면 기존 값에서 +1)
8380
window.count++;
84-
// 3. 이번 구간 안에서 허용치를 넘었는지 판단 — 넘었으면 호출 자체를 막는다
8581
if (window.count > limitPerMinute) {
8682
throw new DocGridException(ErrorCode.RATE_LIMIT_EXCEEDED);
8783
}

backend/src/main/java/com/opensource/docgrid/domain/mcp/tool/DocGridMcpTools.java

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,24 @@
2727

2828
import lombok.extern.slf4j.Slf4j;
2929

30+
/**
31+
* MCP 도구 3종({@code search_documents}, {@code get_document_detail}, {@code get_indexing_status})의
32+
* 핸들러. 새 비즈니스 로직을 만들지 않고 기존 서비스({@link SearchFacade}, {@link PermissionQueryService},
33+
* {@link DocumentQueryService})를 그대로 호출하는 얇은 어댑터다.
34+
*
35+
* <p>도구 3종 공통 제약:
36+
* <ul>
37+
* <li>query 길이 제한: 2000자</li>
38+
* <li>topK 범위: 1~20</li>
39+
* <li>chunkText 길이 제한: 1000자 (검색 결과 반환 시)</li>
40+
* <li>search_documents 호출 제한: 분당 20회</li>
41+
* <li>get_document_detail / get_indexing_status 호출 제한: 분당 30회</li>
42+
* </ul>
43+
*/
3044
@Slf4j
3145
@Component
3246
public class DocGridMcpTools {
3347

34-
/*
35-
전체 MCP 도구 호출에 공통 적용되는 제약 조건
36-
1) query 길이 제한: 2000자
37-
2) topK 범위 제한: 1~20
38-
3) chunkText 길이 제한: 1000자 (검색 결과 반환 시)
39-
4) search_documents 호출 제한: 분당 20회
40-
5) get_document_detail 호출 제한: 분당 30회
41-
6) get_indexing_status 호출 제한: 분당 30회
42-
*/
4348
private static final int MAX_QUERY_LENGTH = 2000;
4449
private static final int MIN_TOP_K = 1;
4550
private static final int MAX_TOP_K = 20;
@@ -133,27 +138,21 @@ public String getIndexingStatus(
133138
* 내부 정보가 클라이언트에 노출되지 않도록 INTERNAL_SERVER_ERROR로 치환한다.
134139
*/
135140
private String executeTool(String toolName, int limitPerMinute, Function<Long, Object> action) {
136-
// 1. McpApiKeyAuthFilter가 SecurityContext에 저장해둔 사용자 식별
137141
Long userId = currentUserId();
138-
// 2. 분당 호출 횟수 제한 확인
139142
rateLimiter.checkLimit(userId, toolName, limitPerMinute);
140143

141144
try {
142-
// 3. 실제 도구 로직 실행
143145
Object result = action.apply(userId);
144-
// 4. JSON으로 직렬화
145146
return toJson(result);
146147
} catch (DocGridException e) {
147-
// 이미 안전한 메시지를 담고 있으므로 그대로 전파
148148
throw e;
149149
} catch (Exception e) {
150-
// 예상치 못한 예외는 내부 정보가 노출되지 않도록 표준 메시지로 치환
151150
log.error("MCP 도구 실행 중 예상하지 못한 오류 toolName={}", toolName, e);
152151
throw new DocGridException(ErrorCode.INTERNAL_SERVER_ERROR);
153152
}
154153
}
155154

156-
// 검색 결과 chunkText가 너무 길면 잘라서 반환 (MCP 도구 호출 시 JSON 응답 크기 제한)
155+
// JSON 응답 크기 제한을 위해 chunkText가 너무 길면 잘라서 반환한다.
157156
private List<SearchResultItem> truncateChunkText(List<SearchResultItem> items) {
158157
return items.stream()
159158
.map(item -> item.chunkText() != null && item.chunkText().length() > MAX_CHUNK_TEXT_LENGTH
@@ -164,14 +163,12 @@ private List<SearchResultItem> truncateChunkText(List<SearchResultItem> items) {
164163
.toList();
165164
}
166165

167-
// MCP 도구 호출 시 documentId는 필수값이므로 null이면 예외를 던진다. )
168166
private void requireDocumentId(Long documentId) {
169167
if (documentId == null) {
170168
throw new DocGridException(ErrorCode.INVALID_PARAMETER, "documentId는 필수입니다.");
171169
}
172170
}
173171

174-
// search_documents 호출 시 query와 topK를 검증한다. query는 null/blank 불가, 길이 제한, topK는 범위 제한.
175172
private void validateSearchInput(String query, Integer topK) {
176173
if (query == null || query.isBlank()) {
177174
throw new DocGridException(ErrorCode.INVALID_PARAMETER, "query는 필수입니다.");
@@ -186,7 +183,7 @@ private void validateSearchInput(String query, Integer topK) {
186183
}
187184
}
188185

189-
// SecurityContext에서 현재 인증된 사용자의 ID를 가져온다. 인증 정보가 없거나 ID가 Long이 아니면 UNAUTHORIZED 예외를 던진다.
186+
// McpApiKeyAuthFilter가 details에 저장해둔 userId를 꺼낸다 — getPrincipal()이 아니라 getDetails().
190187
private Long currentUserId() {
191188
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
192189
if (authentication == null || !(authentication.getDetails() instanceof Long userId)) {
@@ -195,7 +192,6 @@ private Long currentUserId() {
195192
return userId;
196193
}
197194

198-
// Jackson ObjectMapper를 사용해 객체를 JSON 문자열로 직렬화한다. 실패하면 INTERNAL_SERVER_ERROR 예외를 던진다.
199195
private String toJson(Object value) {
200196
try {
201197
return objectMapper.writeValueAsString(value);

backend/src/main/java/com/opensource/docgrid/domain/user/entity/McpAccessToken.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@
1717
import lombok.Getter;
1818
import lombok.NoArgsConstructor;
1919

20+
/**
21+
* MCP API 키 인증에 쓰이는 장기 액세스 토큰.
22+
*
23+
* <p>원본 토큰 값은 저장하지 않고 SHA-256 해시({@link #tokenHash})만 보관한다 — 비밀번호와
24+
* 동일한 원칙이다. {@link com.opensource.docgrid.global.common.entity.BaseEntity}를 상속하지
25+
* 않고 {@code createdAt}을 직접 관리하는 이유는 이 테이블에 {@code updated_at} 컬럼이 없어서다.
26+
*/
2027
@Getter
2128
@Entity
2229
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@@ -39,7 +46,6 @@ public class McpAccessToken {
3946
@Column(name = "token_hash", nullable = false, length = 255)
4047
private String tokenHash;
4148

42-
// BaseEntity 미사용 — updated_at 없는 스키마에 맞춰 직접 관리
4349
@Column(name = "created_at", nullable = false, updatable = false)
4450
private LocalDateTime createdAt;
4551

0 commit comments

Comments
 (0)