Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@
import com.opensource.docgrid.domain.mcp.dto.response.McpAccessTokenRevokeResponse;
import com.opensource.docgrid.domain.user.entity.McpAccessToken;

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

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

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

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

/** 폐기 응답을 만든다. revoke() 처리 직후의 Entity 상태를 그대로 읽으므로 별도 재조회가 없다. */
public McpAccessTokenRevokeResponse toRevokeResponse(McpAccessToken token) {
return new McpAccessTokenRevokeResponse(token.getId(), token.getRevokedAt());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

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

@Schema(description = "MCP 문서 상세 조회 응답")
public record DocumentDetailResponse(
@Schema(description = "문서 ID") Long documentId,
@Schema(description = "문서 제목") String title,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

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

// MCP 토큰 발급 응답 DTO
@Schema(description = "MCP 토큰 발급 응답")
public record McpAccessTokenIssueResponse(
@Schema(description = "토큰 ID") Long tokenId,
@Schema(description = "토큰 원본 값 - 이 응답에서만 1회 노출됨") String token,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

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

// MCP 토큰 목록 조회 응답 DTO
@Schema(description = "MCP 토큰 목록 조회 응답")
public record McpAccessTokenListResponse(
@Schema(description = "내 MCP 토큰 목록") List<McpAccessTokenResponse> tokens
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

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

// MCP 토큰 조회 응답 DTO
@Schema(description = "MCP 토큰 정보")
public record McpAccessTokenResponse(
@Schema(description = "토큰 ID") Long tokenId,
@Schema(description = "발급 시각") LocalDateTime createdAt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

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

// MCP 토큰 폐기 응답 DTO
@Schema(description = "MCP 토큰 폐기 응답")
public record McpAccessTokenRevokeResponse(
@Schema(description = "토큰 ID") Long tokenId,
@Schema(description = "폐기 시각") LocalDateTime revokedAt
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,34 +20,21 @@
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;

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

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

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

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

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

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

// Authorization 헤더에서 "Bearer " 뒤에 붙은 실제 토큰 값만 추출
String token = resolveToken(request);

// 헤더에 토큰이 아예 없으면 인증 시도 자체를 스킵 (아래로 그냥 통과됨)
String token = resolveToken(request); // 토큰 추출
if (StringUtils.hasText(token)) {

// 토큰을 해시화해서 DB(mcp_access_tokens)와 대조 → 유효하면 userId를 담은 Optional 반환
Optional<Long> userId = mcpAccessTokenCommandService.authenticate(token);

// Optional이 값을 갖고 있을 때(=토큰이 유효할 때)만 아래 블록 실행
userId.ifPresent(id -> {

// "인증 성공했다"는 사실을 표현하는 Spring Security 객체를 생성.
// principal 자리엔 실제 이름 대신 "mcp-client"라는 고정 문자열만 넣음
// (JWT 필터처럼 principal에 userId를 바로 넣는 방식과는 다른 패턴이니 주의)
// principal에는 JWT 필터처럼 userId를 바로 넣지 않고 고정 문자열("mcp-client")만
// 넣는다 — API 키엔 email 같은 신원 표시값이 없어서다. 진짜 userId는 details에
// 저장하므로, 도구 핸들러에서 사용자를 식별할 땐 getPrincipal()이 아니라
// getDetails()를 써야 한다.
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken("mcp-client", null, List.of());

// 진짜 userId는 details 필드에 별도로 저장해둔다.
// 나중에 도구 핸들러에서 유저를 식별하려면 getPrincipal()이 아니라 getDetails()를 써야 함
authentication.setDetails(id);

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

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

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

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

import lombok.extern.slf4j.Slf4j;

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

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

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

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

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

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

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

// Jackson ObjectMapper를 사용해 객체를 JSON 문자열로 직렬화한다. 실패하면 INTERNAL_SERVER_ERROR 예외를 던진다.
private String toJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@
import lombok.Getter;
import lombok.NoArgsConstructor;

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

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

Expand Down
Loading