Skip to content

Commit 08bd034

Browse files
authored
Merge pull request #95 from DocGrid/feature/93
[Feat] MCP 서버 골격 및 도구 3종 스켈레톤 등록
2 parents ca1d3e0 + b41887b commit 08bd034

5 files changed

Lines changed: 277 additions & 0 deletions

File tree

build.gradle

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,18 @@ repositories {
1717
mavenCentral()
1818
}
1919

20+
dependencyManagement {
21+
imports {
22+
mavenBom "org.springframework.ai:spring-ai-bom:1.1.8"
23+
}
24+
}
25+
2026
dependencies {
2127
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
2228
implementation 'org.springframework.boot:spring-boot-starter-security'
2329
implementation 'org.springframework.boot:spring-boot-starter-validation'
2430
implementation 'org.springframework.boot:spring-boot-starter-web'
31+
implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
2532
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9'
2633
implementation 'io.minio:minio:8.5.17'
2734
implementation 'org.flywaydb:flyway-core'
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
# #93 MCP 서버 골격 — Spring AI 1.1.x 연동 및 도구 3종 스켈레톤 등록 (F-MCP-01)
2+
3+
closes #93
4+
5+
---
6+
7+
## 배경
8+
9+
DocGrid의 검색·문서조회·인덱싱상태 조회 기능을 MCP(Model Context Protocol) 표준으로 노출해 Claude Desktop 같은 AI Agent가 직접 호출할 수 있게 하는 MCP Server 블록의 첫 번째 이슈다.
10+
11+
**새 기능을 만드는 게 아니라, 이미 완성된 검색/권한/문서조회 로직(`SearchFacade`, `PermissionQueryService`, `DocumentQueryService`)을 표준 프로토콜로 감싸는 어댑터 레이어**를 만드는 게 이 블록 전체의 목표다.
12+
13+
### 왜 이 이슈가 독립적으로 먼저 필요한가
14+
15+
MCP 명세를 처음 설계할 때는 `POST /mcp {method: "list_tools"}` 같은 커스텀 JSON 프로토콜을 직접 만드는 방향이었다. 하지만 실제 MCP 표준은 **JSON-RPC 2.0** (`tools/list`, `tools/call`)이라, 커스텀 프로토콜을 그대로 구현하면 실제 Claude Desktop 같은 클라이언트와는 연동되지 않는다.
16+
17+
리서치 결과 공식 MCP Java SDK를 감싼 **Spring AI MCP Boot Starter**를 쓰면 표준 프로토콜 파싱/직렬화를 대신 처리해준다는 걸 확인했다. 다만:
18+
19+
- Spring AI **2.0**은 Spring Boot 4.0 + Java 21이 필수라 이 프로젝트(Spring Boot 3.5.16 / Java 17)와 호환되지 않는다 → **Spring AI 1.1.x 라인**(Spring Boot 3.3+ 지원) 채택.
20+
- 이 SDK 연동은 이 프로젝트에서 처음 시도하는 것이라, "어노테이션 스캔이 실제로 동작하는가"가 검증되지 않은 리스크였다. 그래서 실제 검색/권한 로직을 붙이기 전에 **빈 도구 3개만 등록해서 SDK 연동 자체를 먼저 검증**하는 것이 이 이슈의 목적이다.
21+
22+
---
23+
24+
## 사전 기술 검증
25+
26+
문서 요약만 믿지 않고 실제 의존성 jar을 받아서 `javap`로 클래스를 직접 열어 확인했다.
27+
28+
```bash
29+
./gradlew dependencies --configuration compileClasspath | grep -iE "mcp|spring-ai"
30+
```
31+
32+
확인된 사실:
33+
34+
| 항목 | 확인 결과 |
35+
| --- | --- |
36+
| `@McpTool` / `@McpToolParam` 패키지 | `org.springaicommunity.mcp.annotation` — Spring AI 코어가 아니라 `org.springaicommunity:mcp-annotations`(별도 커뮤니티 프로젝트) 소속. Spring AI 1.1.x가 이 라이브러리를 의존성으로 끌어다 씀. |
37+
| `@McpTool` 속성 | `name()`, `description()`, `annotations()`, `generateOutputSchema()`, `title()`, `metaProvider()` |
38+
| `@McpToolParam` 속성 | `required()`, `description()``name()` 없음. 파라미터 이름은 컴파일러의 `-parameters` 플래그로 보존된 실제 자바 파라미터명을 사용 (Spring Boot Gradle 플러그인이 기본으로 활성화, 프로젝트 컴파일 결과에서 `MethodParameters` 속성으로 실제 확인됨). |
39+
| `CallToolResult` 위치 | `io.modelcontextprotocol.spec.McpSchema.CallToolResult` (MCP 공식 Java SDK, `io.modelcontextprotocol.sdk:mcp-core`) |
40+
| 설정 프로퍼티 키 | `spring.ai.mcp.server.{name,protocol,type,enabled}`, `spring.ai.mcp.server.annotation-scanner.enabled``McpServerProperties`/`McpServerAnnotationScannerProperties` 클래스 필드로 직접 확인 |
41+
| `protocol` enum 값 | `SSE`, `STREAMABLE`, `STATELESS` |
42+
| `type` enum 값 | `SYNC`, `ASYNC` |
43+
| 기본 엔드포인트 경로 | `/mcp` (`McpServerStreamableHttpProperties.mcpEndpoint` 기본값) |
44+
| 도구 등록 메커니즘 | `ServerAnnotatedMethodBeanPostProcessor`(`BeanPostProcessor`)가 모든 빈을 스캔해 `@McpTool` 메서드를 찾아 `ServerMcpAnnotatedBeans`에 등록 → `McpServerSpecificationFactoryAutoConfiguration`이 이를 `McpServerFeatures.SyncToolSpecification` 목록으로 변환 |
45+
46+
**중요한 발견 — 알려진 SDK 버그 회피 경로 확인**: GitHub 이슈 [#4882](https://github.com/spring-projects/spring-ai/issues/4882)`spring-ai-starter-mcp-server-webmvc:1.1.0`에서 **STATELESS+SYNC** 모드로 AOP 프록시된 빈에 `@McpTool`을 붙이면 도구 등록이 실패하는 버그다(아직 OPEN). 하지만 위 표에서 확인했듯 `STREAMABLE`+`SYNC` 조합은 `StatelessServerSpecificationFactoryAutoConfiguration`이 아니라 **`McpServerSpecificationFactoryAutoConfiguration`**을 타는 전혀 다른 클래스 경로다 — 즉 구조적으로 이 버그의 영향을 받지 않는다는 것을 코드로 직접 확인했다. (`DocGridMcpTools``@Transactional` 등 AOP 프록시를 유발하는 어노테이션 없이 순수 `@Component`로 유지해 이중으로 안전하게 만들었다.)
47+
48+
또한 이슈 [#4392](https://github.com/spring-projects/spring-ai/issues/4392)(`annotation-scanner.enabled=true`에도 `@McpTool` 빈이 등록 안 되는 버그, 1.1.0-M1에서 보고)는 milestone 1.1.1로 CLOSED됨을 확인했고, `spring-ai-bom` 최신 1.1.x 패치인 **1.1.8**을 사용해 이 버그도 회피했다.
49+
50+
---
51+
52+
## 신규/변경 파일
53+
54+
### build.gradle
55+
56+
```gradle
57+
dependencyManagement {
58+
imports {
59+
mavenBom "org.springframework.ai:spring-ai-bom:1.1.8"
60+
}
61+
}
62+
63+
dependencies {
64+
implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
65+
}
66+
```
67+
68+
`1.1.8``repo1.maven.org``maven-metadata.xml`을 직접 조회해 확인한 1.1.x 라인의 최신 패치 버전이다 (`<latest>`/`<release>`는 2.0.0이지만, 2.0.0은 Spring Boot 4.0 필수라 배제).
69+
70+
---
71+
72+
### application.yml
73+
74+
```yaml
75+
spring:
76+
ai:
77+
mcp:
78+
server:
79+
name: docgrid-mcp-server
80+
protocol: STREAMABLE
81+
type: SYNC
82+
annotation-scanner:
83+
enabled: true
84+
```
85+
86+
- `protocol: STREAMABLE`: stdio가 아닌 HTTP 기반 원격 서버로 동작하도록 설정 (DocGrid는 이미 Spring Boot 웹서버이므로 자연스러운 선택).
87+
- `type: SYNC`: 요청을 동기적으로 처리하는 서버 모드. DocGrid의 기존 서비스 계층(`SearchFacade` 등)이 전부 동기 방식이라 일치시킴.
88+
- `annotation-scanner.enabled: true`: `@McpTool` 어노테이션이 붙은 빈을 자동 스캔해 도구로 등록.
89+
90+
---
91+
92+
### domain/mcp/tool/DocGridMcpTools.java
93+
94+
```java
95+
@Component
96+
public class DocGridMcpTools {
97+
98+
@McpTool(name = "search_documents",
99+
description = "사용자 질문과 관련된 문서 chunk를 벡터 검색으로 찾는다. 권한이 있는 문서만 반환된다.")
100+
public String searchDocuments(
101+
@McpToolParam(description = "검색어", required = true) String query,
102+
@McpToolParam(description = "반환할 최대 결과 수 (기본 5, 1~20)", required = false) Integer topK) {
103+
// TODO: SearchFacade 연동 (다음 이슈에서 구현)
104+
return "not implemented";
105+
}
106+
107+
@McpTool(name = "get_document_detail",
108+
description = "특정 문서의 메타데이터와 현재 버전 정보를 조회한다. 권한이 있는 문서만 조회 가능하다.")
109+
public String getDocumentDetail(
110+
@McpToolParam(description = "문서 ID", required = true) Long documentId) {
111+
// TODO: PermissionQueryService + DocumentQueryService 연동 (다음 이슈에서 구현)
112+
return "not implemented";
113+
}
114+
115+
@McpTool(name = "get_indexing_status",
116+
description = "특정 문서 또는 버전의 인덱싱 상태(PENDING/PROCESSING/INDEXED/FAILED)를 조회한다.")
117+
public String getIndexingStatus(
118+
@McpToolParam(description = "문서 ID", required = false) Long documentId,
119+
@McpToolParam(description = "버전 ID", required = false) Long versionId) {
120+
// TODO: PermissionQueryService + DocumentQueryService 연동 (다음 이슈에서 구현)
121+
return "not implemented";
122+
}
123+
}
124+
```
125+
126+
- 반환 타입을 `CallToolResult`가 아니라 `String`으로 둔 이유: SDK의 `AbstractMcpToolMethodCallback.convertValueToCallToolResult(Object)`가 `String`/POJO 같은 단순 반환값을 자동으로 `CallToolResult`(TEXT 콘텐츠)로 감싸준다. 스텁 단계에서는 이 자동 래핑으로 충분하다.
127+
- 파라미터 시그니처는 F-MCP-02/03/04 명세의 입력 스키마(`query`+`topK`, `documentId`, `documentId`+`versionId`)를 그대로 반영.
128+
129+
---
130+
131+
### global/config/SecurityConfig.java
132+
133+
```java
134+
.requestMatchers("/auth/signup", "/auth/login").permitAll()
135+
// TODO: 임시 permitAll — 다음 이슈에서 McpApiKeyAuthFilter로 교체 예정
136+
.requestMatchers("/mcp/**").permitAll()
137+
.requestMatchers("/admin/**").hasRole("ADMIN")
138+
```
139+
140+
MCP 인증(F-MCP-07, API 키 방식)은 다음 이슈에서 구현하므로, 이번 이슈에서는 `/mcp/**`를 임시로 인증 예외 처리하고 골격 검증에만 집중한다.
141+
142+
---
143+
144+
## 완료 조건 검증
145+
146+
로컬로 실제 서버를 기동해 JSON-RPC `initialize` → `tools/list` 흐름을 curl로 직접 호출해 확인했다.
147+
148+
**initialize 응답:**
149+
```json
150+
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{...},"serverInfo":{"name":"docgrid-mcp-server","version":"1.0.0"}}}
151+
```
152+
153+
**tools/list 응답 (일부):**
154+
```json
155+
{
156+
"jsonrpc":"2.0","id":2,
157+
"result":{
158+
"tools":[
159+
{
160+
"name":"search_documents",
161+
"description":"사용자 질문과 관련된 문서 chunk를 벡터 검색으로 찾는다. 권한이 있는 문서만 반환된다.",
162+
"inputSchema":{
163+
"type":"object",
164+
"properties":{
165+
"query":{"type":"string","description":"검색어"},
166+
"topK":{"type":"integer","format":"int32","description":"반환할 최대 결과 수 (기본 5, 1~20)"}
167+
},
168+
"required":["query"]
169+
}
170+
}
171+
]
172+
}
173+
}
174+
```
175+
176+
(`get_document_detail`, `get_indexing_status`도 동일하게 정상 반환됨 — 지면상 `search_documents`만 발췌)
177+
178+
애플리케이션 로그에서도 `Registered tools: 3`을 확인했다. 도구 3종의 `name`/`description`/`inputSchema`(필수값 포함)가 어노테이션 메타데이터로부터 정확히 자동 생성됨을 실제 데이터로 증명했다 — 이 이슈의 목적이었던 "SDK 연동 리스크 검증"이 해소됐다.
179+
180+
---
181+
182+
## 에러 코드 (참고, 이번 이슈에서는 미구현)
183+
184+
실제 로직이 없는 스텁 단계라 에러 케이스는 아직 발생하지 않는다. 다음 이슈들에서 아래 명세를 따라 구현할 예정이다.
185+
186+
| 코드 | 의미 | 구현 예정 이슈 |
187+
| --- | --- | --- |
188+
| `VALIDATION_ERROR` | 입력 파라미터 검증 실패 (query 2000자 초과, topK 범위 초과 등) | search_documents / detail+status 도구 이슈 |
189+
| `UNAUTHORIZED` | 인증 실패 (API 키 없음/폐기됨/불일치) | 인증 인프라 이슈 |
190+
| `PERMISSION_DENIED` | 권한 없음 | detail+status 도구 이슈 |
191+
| `DOCUMENT_NOT_FOUND` | 문서 없음 | detail+status 도구 이슈 |
192+
| `RATE_LIMIT_EXCEEDED` | 호출 횟수 초과 | rate limit 이슈 |
193+
194+
---
195+
196+
## 설계 결정 요약
197+
198+
**Spring AI 2.0이 아닌 1.1.x 채택**
199+
2.0은 Spring Boot 4.0 + Java 21이 필수라 이 프로젝트와 호환 불가. 1.1.x는 Spring Boot 3.3+를 지원해 현재 스택(3.5.16)과 맞는다.
200+
201+
**빈 도구 등록을 별도 이슈로 분리**
202+
SDK 연동이 이 프로젝트에서 처음이라 검증되지 않은 리스크였다. 실제 검색/권한 로직을 붙이기 전에 등록 자체가 되는지부터 확인해, 만약 안 됐다면 `@Tool` + `ToolCallbackProvider` 수동 등록으로 전환하는 결정을 이 시점에 내릴 수 있게 리스크를 앞당겼다.
203+
204+
**`/mcp/**` 임시 permitAll**
205+
인증 인프라(`mcp_access_tokens` 재사용, API 키 필터)는 별도 이슈 규모라 이번 이슈 범위에서 뺐다. TODO 주석으로 다음 이슈에서 반드시 교체하도록 명시.
206+
207+
---
208+
209+
## 남은 이슈 / TODO
210+
211+
### 이번 이슈에서 반영 완료
212+
213+
- **`@McpTool`의 `annotations` 속성**: 최초 구현에서는 미설정 상태라 `tools/list` 응답에 `readOnlyHint: false`, `destructiveHint: true`가 기본값으로 나갔다. 이 3개 도구는 전부 조회 전용(read-only)인데 반대로 선언돼 있어 MCP 클라이언트가 불필요한 승인(approval) UX를 붙일 수 있다는 점을 CodeRabbit 리뷰로도 재확인해, `@McpTool(..., annotations = @McpTool.McpAnnotations(readOnlyHint = true, destructiveHint = false))`를 도구 3개 모두에 명시했다.
214+
215+
### 다음 이슈에서 반영 필요
216+
217+
- **`CallToolResult` 명시적 사용 필요**: `VALIDATION_ERROR`/`PERMISSION_DENIED` 같은 에러를 `isError=true`로 표현하려면 단순 `String` 반환의 자동 래핑으로는 부족할 가능성이 높다 — `CallToolResult.builder().isError(true)...`를 명시적으로 써야 하는지 실제 로직 구현 시 확인.
218+
- **`/mcp/**` permitAll 제거**: 인증 인프라 이슈에서 `McpApiKeyAuthFilter`로 교체.
219+
220+
### 다음 단계
221+
222+
인증 인프라(API 키 발급/조회/폐기 + 인증 필터) 구현 이슈로 이어진다. 이후 `search_documents` → `get_document_detail`/`get_indexing_status` → Rate Limiting/출력 정제 → Claude Desktop 연동 검증 순서로 진행한다.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.opensource.docgrid.domain.mcp.tool;
2+
3+
import org.springaicommunity.mcp.annotation.McpTool;
4+
import org.springaicommunity.mcp.annotation.McpToolParam;
5+
import org.springframework.stereotype.Component;
6+
7+
@Component
8+
public class DocGridMcpTools {
9+
10+
@McpTool(name = "search_documents",
11+
description = "사용자 질문과 관련된 문서 chunk를 벡터 검색으로 찾는다. 권한이 있는 문서만 반환된다.",
12+
annotations = @McpTool.McpAnnotations(readOnlyHint = true, destructiveHint = false))
13+
public String searchDocuments(
14+
@McpToolParam(description = "검색어", required = true) String query,
15+
@McpToolParam(description = "반환할 최대 결과 수 (기본 5, 1~20)", required = false) Integer topK) {
16+
// TODO: SearchFacade 연동 (다음 이슈에서 구현)
17+
return "not implemented";
18+
}
19+
20+
@McpTool(name = "get_document_detail",
21+
description = "특정 문서의 메타데이터와 현재 버전 정보를 조회한다. 권한이 있는 문서만 조회 가능하다.",
22+
annotations = @McpTool.McpAnnotations(readOnlyHint = true, destructiveHint = false))
23+
public String getDocumentDetail(
24+
@McpToolParam(description = "문서 ID", required = true) Long documentId) {
25+
// TODO: PermissionQueryService + DocumentQueryService 연동 (다음 이슈에서 구현)
26+
return "not implemented";
27+
}
28+
29+
@McpTool(name = "get_indexing_status",
30+
description = "특정 문서 또는 버전의 인덱싱 상태(PENDING/PROCESSING/INDEXED/FAILED)를 조회한다.",
31+
annotations = @McpTool.McpAnnotations(readOnlyHint = true, destructiveHint = false))
32+
public String getIndexingStatus(
33+
@McpToolParam(description = "문서 ID", required = false) Long documentId,
34+
@McpToolParam(description = "버전 ID", required = false) Long versionId) {
35+
// TODO: PermissionQueryService + DocumentQueryService 연동 (다음 이슈에서 구현)
36+
return "not implemented";
37+
}
38+
}

src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
3636
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
3737
.requestMatchers("/departments").permitAll()
3838
.requestMatchers("/auth/signup", "/auth/login").permitAll()
39+
// TODO: 임시 permitAll — 다음 이슈에서 McpApiKeyAuthFilter로 교체 예정
40+
.requestMatchers("/mcp/**").permitAll()
3941
.requestMatchers("/admin/**").hasRole("ADMIN")
4042
.anyRequest().authenticated()
4143
)

src/main/resources/application.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ spring:
99
multipart:
1010
max-file-size: 10MB
1111
max-request-size: 11MB
12+
ai:
13+
mcp:
14+
server:
15+
name: docgrid-mcp-server
16+
protocol: STREAMABLE
17+
type: SYNC
18+
annotation-scanner:
19+
enabled: true
1220
task:
1321
scheduling:
1422
pool:

0 commit comments

Comments
 (0)