외부 에이전트 연결을 MCP 로 열고, Java SDK 코어의 세션 없는 서블릿 전송으로 붙인다
- 상태: 채택됨
- 작성일: 2026-10-01
- 관련 문서: PRO-54 · APP-801 · APP-803
맥락
제품 정의가 「팀원이 위임한 외부 에이전트에서 프로젝트 기억을 읽는다」를 정했고, 접속 방식과 프로토콜은 일부러 정하지 않았다. PRO-54 PRD 는 1단계를 개인 토큰, 2단계를 OAuth 2.1 로 나눴다. 남은 것은 무슨 프로토콜로, 무엇으로 구현하나였고, 다음이 이미 정해져 있었다.
- 에이전트는 MCP 로 붙는다. 연결 대상인 Claude Code·Codex CLI·claude.ai·ChatGPT 가 원격 도구 서버로 받는 것이 MCP 다(PRD 「연결 방식」 표).
- server 태스크가 여럿이고 ALB 에 고정 세션이 없다. 요청마다 다른 태스크로 갈 수 있어, 서버가 세션을 기억하는 전송은 쓸 수 없다.
- server 는 Spring Boot 4.1 · Jackson 3(
tools.jackson) · Java 25 이다. 구현체가 Jackson 2 에 묶여 있으면 매퍼가 두 벌이 된다. - 인증은 쿠키와 섞으면 안 된다. 브라우저에 로그인된 쿠키만으로 MCP 를 부를 수 있으면 위임이 아무 의미가 없다.
- 사용 내역에 본문을 남기지 않는다. 그러면서도 「어느 도구를 몇 건, 성공했나」는 요청마다 남아야 한다.
결정
- 프로토콜은 MCP 다. 원격 전송은 Streamable HTTP 의 세션 없는(stateless) 형태만 쓴다. 요청 하나가 자기 안에서 끝나므로 어느 태스크가 받아도 된다.
- 구현체는 MCP Java SDK 2.0.1 의
mcp아티팩트다. POM 기준으로mcp-core와mcp-json-jackson3둘로 이뤄져 Jackson 3 을 쓴다. 코어에 세션 없는 서블릿 전송(HttpServletStatelessServerTransport), 요청 맥락 추출기, JSON Schema 검사기가 있어 이것만으로 필요한 것이 다 된다.- 전송은 서블릿으로
/mcp에 따로 등록한다. 보안 필터는 서블릿을 가리지 않으므로 Spring Security 체인을 그대로 지난다. - 이 전송은 처리를 끝까지 기다렸다가 응답한다(바이트코드에서
Mono.block확인). 그래서 인증 필터가 체인에서 돌아온 시점에 도구 결과를 알고 사용 내역을 남길 수 있다. 이 성질이 깨지면 사용 내역 기록 자리를 옮겨야 한다.
- 전송은 서블릿으로
/mcp는 전용 보안 체인을 갖는다./internal체인과 같은 모양이고, 쿠키를 보지 않는다. Origin 검사 → 개인 토큰 → 위임 판정(APP-801 의ResolveAgentDelegationHandler) → 요청 상한 → 사용 기록(유휴 만료 연장) → 처리 → 사용 내역 순서다. 토큰 거절은 모두 같은 401 이고, 상한은 429 와Retry-After다.- 판정은 읽기만 하고, 만료를 미루는 사용 기록은 상한을 통과한 요청에만 한다. 429 만 받는 토큰이 만료를 미루면 새어 나간 토큰이 상한에 막히면서도 살아남는다. 회수를 덮어쓰지 않도록 위임 행을 잠그는 것은 이 기록이다.
- 사용 기록은 잠근 채 위임이 살아 있는지 다시 본다. 판정은 잠그지 않아 판정과 기록 사이에 회수가 끼어들 수 있고, 그러면 같은 401 로 끝난다.
- 사용 내역은 응답 복사가 실패해도(클라이언트가 먼저 끊음) 남긴다. 안 그러면 끊는 요청이 상한에 들지 않는다.
- 입력 검사는 도구 쪽에서 한다. SDK 의 앞단 검사를 끄고 같은 검사기로 도구 감싸개 안에서 검사한다. 앞단에서 걸리면 도구에 닿지 않아 사용 내역에 어느 도구가 실패했는지가 남지 않는다.
- 브라우저 Origin 이 실린 요청은 거절한다. 허용 Origin 을 두지 않는다 — 에이전트는 Origin 을 싣지 않는다. 검사는 SDK 의 Origin 검사기가 아니라 토큰 필터 맨 앞에서 한다. SDK 에서 거절하면 그 전에 사용 기록과 상한 집계가 끝나 있어, 이런 요청이 만료를 미루고 정상 호출의 상한을 채운다. SDK 전송의 검사기는 기본값(검사 없음)으로 둔다.
- MCP 도구의 계약 원본은 server 코드의 도구 정의다. 지금 열린 도구는 연결 정보 하나라 docs
contracts/에 아직 옮기지 않는다. 프로젝트 기억을 읽는 도구가 생기는 APP-805 에서contracts/에 바깥 경계 문서를 새로 두고 도구 이름·입력·출력을 옮긴다. 기존 두 경계(server↔web·server↔ai) 어느 쪽도 아니기 때문이다.
대안
대안 1 — Spring AI MCP 서버 스타터. 애너테이션으로 도구를 선언하고 자동 구성이 전송·서버를 세워 준다. 기각한 이유: 필요한 것이 SDK 코어에 다 있어 자동 구성 계층을 더 들일 이유가 없다. 보안 체인과 사용 내역 기록은 어차피 우리 필터가 쥐어야 한다. Spring AI 가 이 server 조합(Boot 4.1.1 · Kotlin 2.3 · Java 25)에서 도는지는 실측하지 않았다.
대안 2 — JSON-RPC 를 손으로 구현. 세션 없는 서버는 POST 하나에 JSON 하나라 작게 쓸 수 있다. 기각한 이유: 초기화 협상·도구 목록·입력 검사·오류 형식을 표준대로 맞추는 일을 우리가 지게 된다. 클라이언트별 호환은 SDK 가 이미 맞춰 둔 부분이다.
대안 3 — 세션 있는 Streamable HTTP. 서버가 클라이언트에 먼저 말을 걸 수 있다(진행 알림 등). 기각한 이유: 세션을 기억해야 해 고정 세션이나 공유 저장소가 필요하다. 읽기 도구에는 서버가 먼저 말할 일이 없다.
결과
- 실제 클라이언트로 붙었다. 2026-10-01 로컬 server 에 Claude Code 2.1.274 와 Codex CLI 0.157.1 이 각각 개인 토큰(Bearer)으로 붙어 도구 목록을 받고 연결 정보 도구를 불렀다. 사용 내역에는 도구 이름·건수·소요 시간만 남았다.
- server 는 MCP 프로토콜
2025-11-25로 답한다(초기화 응답에서 확인). 최신 규격과의 차이는 SDK 가 따라오는 대로 올린다. - 2단계 OAuth 는 이 체인의 「토큰 → 위임」 판정 자리에 OAuth 토큰 검증을 하나 더하는 일이다. 전송과 도구는 그대로다.
- 감수한 대가: 서블릿을 DispatcherServlet 밖에 두어 MockMvc 가 닿지 않는다 — 시험은 실제 포트로 HTTP 를 보낸다.
클라이언트가 서버 푸시 스트림을 열어 보려는
GET /mcp는 405 로 끝나고 사용 내역에 실패로 남는다(정상 동작). - 확인하지 못한 것: claude.ai·ChatGPT 로 붙는 것 — 2단계(OAuth) 일이다.
- 되돌리는 신호: 도구가 진행 알림이나 서버 쪽 요청을 필요로 하게 되면 세션 있는 전송을 다시 본다. 그때는 세션을 어디에 둘지(공유 저장소)가 먼저다.