diff --git a/API명세서.md b/API명세서.md index 453fd58..ddc38b8 100644 --- a/API명세서.md +++ b/API명세서.md @@ -4,7 +4,7 @@ Dewey MCP (Model Context Protocol) 서버는 JSON-RPC 2.0 프로토콜을 기반으로 AI 장기기억 시스템의 기능을 제공합니다. -- **프로토콜 버전**: 2024-11-05 +- **프로토콜 버전**: 2025-06-18 (클라이언트가 보낸 버전 사용, 기본값: 2024-11-05) - **서버 이름**: Dewey Memory Server - **서버 버전**: 0.0.1-SNAPSHOT - **Base URL**: `http://localhost:8080/mcp` @@ -40,6 +40,44 @@ Database (PostgreSQL / Redis) ## 엔드포인트 +### GET /mcp + +서버 상태를 확인하는 헬스 체크 엔드포인트입니다. + +**요청:** +``` +GET http://localhost:8080/mcp +``` + +**응답:** +```json +{ + "status": "ok", + "service": "Dewey MCP Server", + "version": "0.0.1-SNAPSHOT", + "protocol": "JSON-RPC 2.0", + "endpoint": "/mcp" +} +``` + +--- + +### OPTIONS /mcp + +CORS preflight 요청을 처리하는 엔드포인트입니다. + +**요청:** +``` +OPTIONS http://localhost:8080/mcp +``` + +**응답:** +``` +HTTP 200 OK +``` + +--- + ### POST /mcp 모든 MCP 요청은 이 단일 엔드포인트로 처리됩니다. 요청 본문은 JSON-RPC 2.0 형식을 따릅니다. @@ -65,10 +103,15 @@ MCP 서버를 초기화하고 서버 정보 및 지원 기능을 반환합니다 "id": "1", "method": "initialize", "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, + "protocolVersion": "2025-06-18", + "capabilities": { + "tools": true, + "prompts": true, + "resources": true, + "logging": false + }, "clientInfo": { - "name": "client-name", + "name": "cursor-vscode", "version": "1.0.0" } } @@ -82,11 +125,20 @@ MCP 서버를 초기화하고 서버 정보 및 지원 기능을 반환합니다 "jsonrpc": "2.0", "id": "1", "result": { - "protocolVersion": "2024-11-05", + "protocolVersion": "2025-06-18", "capabilities": { - "tools": {}, - "resources": {}, - "prompts": {} + "tools": { + "listChanged": true, + "supported": true + }, + "resources": { + "listChanged": false, + "supported": true + }, + "prompts": { + "listChanged": false, + "supported": false + } }, "serverInfo": { "name": "Dewey Memory Server", @@ -96,11 +148,43 @@ MCP 서버를 초기화하고 서버 정보 및 지원 기능을 반환합니다 } ``` +**참고:** +- `protocolVersion`: 클라이언트가 보낸 버전을 그대로 반환합니다 (호환성) +- `capabilities.tools.listChanged`: `true`로 설정하여 Cursor가 `tools/list`를 호출하도록 유도합니다 +- `capabilities.tools.supported`: `true`로 설정 (도구 기능 지원) +- `capabilities.resources.listChanged`: `false`로 설정 (리소스 목록 변경 없음) +- `capabilities.resources.supported`: `true`로 설정 (리소스 기능 지원) +- `capabilities.prompts.listChanged`: `false`로 설정 (프롬프트 목록 변경 없음) +- `capabilities.prompts.supported`: `false`로 설정 (프롬프트 기능 미지원) + **응답 DTO**: `McpInitializeResponse` +**참고:** `initialize` 요청 후 클라이언트는 `initialized` 알림을 보낼 수 있습니다. 이 알림은 응답이 필요 없습니다 (notification). + --- -### 2. tools/list +### 2. initialized (알림) + +`initialize` 요청 후 클라이언트가 보내는 알림입니다. 응답이 필요 없습니다. + +#### 요청 + +```json +{ + "jsonrpc": "2.0", + "method": "initialized" +} +``` + +**참고:** `id` 필드가 없으면 알림(notification)으로 처리됩니다. + +#### 응답 + +응답이 필요 없습니다. HTTP 200 OK만 반환됩니다. + +--- + +### 3. tools/list 사용 가능한 모든 도구 목록을 반환합니다. @@ -195,9 +279,11 @@ MCP 서버를 초기화하고 서버 정보 및 지원 기능을 반환합니다 **응답 DTO**: `McpToolResponse.ToolListResponse` +**참고:** `initialize` 응답에서 `capabilities.tools.listChanged: true`로 설정하면 클라이언트가 이 메서드를 호출합니다. + --- -### 3. tools/call +### 4. tools/call 특정 도구를 실행합니다. 도구 실행 결과에 따라 응답 내용이 달라집니다. @@ -389,7 +475,7 @@ MCP 서버를 초기화하고 서버 정보 및 지원 기능을 반환합니다 --- -### 4. resources/list +### 5. resources/list 사용 가능한 모든 리소스 목록을 반환합니다. @@ -432,7 +518,7 @@ MCP 서버를 초기화하고 서버 정보 및 지원 기능을 반환합니다 --- -### 5. resources/read +### 6. resources/read 특정 리소스의 내용을 읽습니다. @@ -486,7 +572,7 @@ MCP 서버를 초기화하고 서버 정보 및 지원 기능을 반환합니다 --- -### 6. prompts/list +### 7. prompts/list 사용 가능한 모든 프롬프트 목록을 반환합니다. @@ -539,7 +625,7 @@ MCP 서버를 초기화하고 서버 정보 및 지원 기능을 반환합니다 --- -### 7. prompts/get +### 8. prompts/get 특정 프롬프트를 가져옵니다. @@ -602,6 +688,11 @@ MCP 서버를 초기화하고 서버 정보 및 지원 기능을 반환합니다 } ``` +**참고:** +- 정상 응답일 때는 `error` 필드가 포함되지 않습니다 (JSON-RPC 2.0 표준) +- 에러 응답일 때는 `result` 필드가 포함되지 않습니다 +- `@JsonInclude(JsonInclude.Include.NON_NULL)` 어노테이션으로 null 값 필드는 자동 제외됩니다 + ### 에러 코드 | 코드 | 의미 | 설명 | @@ -614,12 +705,22 @@ MCP 서버를 초기화하고 서버 정보 및 지원 기능을 반환합니다 **에러 처리**: `GlobalExceptionHandler`에서 전역 예외 처리 +**추가 예외 처리:** +- `HttpRequestMethodNotSupportedException`: 지원되지 않는 HTTP 메서드 (405 Method Not Allowed) +- `HttpMessageNotReadableException`: JSON 파싱 오류 (400 Bad Request, JSON-RPC 2.0 에러 형식으로 반환) + --- ## 사용 예시 ### cURL 예시 +#### 헬스 체크 + +```bash +curl -X GET http://localhost:8080/mcp +``` + #### initialize ```bash @@ -630,8 +731,12 @@ curl -X POST http://localhost:8080/mcp \ "id": "1", "method": "initialize", "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, + "protocolVersion": "2025-06-18", + "capabilities": { + "tools": true, + "prompts": true, + "resources": true + }, "clientInfo": { "name": "test-client", "version": "1.0.0" @@ -795,6 +900,8 @@ public class MemoryResponse { ### 4. CORS 설정 - CORS는 모든 origin에서 허용되도록 설정되어 있습니다 (`/mcp/**`). +- 허용된 HTTP 메서드: `GET`, `POST`, `OPTIONS` +- 허용된 헤더: 모든 헤더 (`*`) - 프로덕션 환경에서는 특정 origin만 허용하도록 변경하는 것을 권장합니다. ### 5. 예외 처리 @@ -802,24 +909,50 @@ public class MemoryResponse { - 전역 예외 처리는 `GlobalExceptionHandler`에서 수행됩니다. - 유효성 검증 오류는 `MethodArgumentNotValidException`으로 처리됩니다. - 모든 예외는 JSON-RPC 2.0 에러 형식으로 변환됩니다. +- `HttpRequestMethodNotSupportedException`: 405 Method Not Allowed 응답 반환 +- `HttpMessageNotReadableException`: JSON 파싱 오류를 JSON-RPC 2.0 에러 형식으로 변환하여 반환 + +### 6. 로깅 + +- 모든 요청과 응답이 상세하게 로깅됩니다. +- 요청 본문은 JSON 포맷으로 로깅됩니다 (`=== MCP 요청 수신 ===`). +- 응답 본문은 JSON 포맷으로 로깅됩니다 (`=== MCP 응답 전송 ===`). +- 에러 응답도 별도로 로깅됩니다 (`=== MCP 에러 응답 전송 ===`). +- 헬스 체크와 OPTIONS 요청도 로깅됩니다. ### 6. 구현 상태 - ✅ 기본 MCP 프로토콜 구현 완료 - ✅ 도메인 모델 및 Repository 생성 완료 - ✅ MVC 구조 재설계 완료 +- ✅ 헬스 체크 엔드포인트 추가 (GET /mcp) +- ✅ CORS preflight 처리 (OPTIONS /mcp) +- ✅ initialized 알림 처리 +- ✅ 예외 처리 개선 (HTTP 메서드, JSON 파싱) +- ✅ `capabilities`에 `supported` 필드 추가 +- ✅ JSON-RPC 2.0 표준 준수 (정상 응답에 error 필드 제외) +- ✅ 요청/응답 전체 로깅 기능 추가 - ⏳ Redis 연동 (TODO) - ⏳ 벡터 검색 구현 (TODO) - ⏳ 배치 처리 구현 (TODO) +### 7. Cursor IDE 연동 + +- ✅ Cursor IDE에서 HTTP 기반 MCP 서버로 연결 성공 +- ✅ `mcp.json` 설정 파일에서 `url` 필드로 서버 주소 지정 +- ✅ `tools/list` 요청 정상 처리 확인 +- ✅ `capabilities.supported` 필드로 기능 지원 여부 명시 + --- ## 버전 정보 -- **문서 버전**: 2.0.0 +- **문서 버전**: 2.2.0 - **최종 업데이트**: 2025-12-10 - **서버 버전**: 0.0.1-SNAPSHOT - **아키텍처**: MVC (Model-View-Controller) +- **프로토콜 버전**: 2025-06-18 (클라이언트 호환) +- **상태**: ✅ Cursor IDE 연동 성공 --- diff --git a/src/main/java/com/pandol365/dewey/api/controller/McpController.java b/src/main/java/com/pandol365/dewey/api/controller/McpController.java index 0d222a2..007cbd1 100644 --- a/src/main/java/com/pandol365/dewey/api/controller/McpController.java +++ b/src/main/java/com/pandol365/dewey/api/controller/McpController.java @@ -1,5 +1,7 @@ package com.pandol365.dewey.api.controller; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.pandol365.dewey.api.dto.request.McpJsonRpcRequest; import com.pandol365.dewey.api.dto.response.McpJsonRpcResponse; import com.pandol365.dewey.api.service.McpService; @@ -23,6 +25,7 @@ import java.util.Map; public class McpController { private final McpService mcpService; + private final ObjectMapper objectMapper; /** * 헬스 체크 엔드포인트 (GET 요청 처리) @@ -30,13 +33,22 @@ public class McpController { */ @GetMapping(produces = {"application/json", "text/plain", "*/*"}) public ResponseEntity> healthCheck() { - log.info("헬스 체크 요청 수신"); + log.info("=== 헬스 체크 요청 수신 ==="); + Map response = new HashMap<>(); response.put("status", "ok"); response.put("service", "Dewey MCP Server"); response.put("version", "0.0.1-SNAPSHOT"); response.put("protocol", "JSON-RPC 2.0"); response.put("endpoint", "/mcp"); + + try { + String responseJson = objectMapper.writeValueAsString(response); + log.info("=== 헬스 체크 응답 전송 ===\n{}", responseJson); + } catch (JsonProcessingException e) { + log.warn("응답 JSON 변환 실패: {}", e.getMessage()); + } + return ResponseEntity.ok() .header("Content-Type", "application/json") .body(response); @@ -47,7 +59,8 @@ public class McpController { */ @RequestMapping(method = RequestMethod.OPTIONS) public ResponseEntity options() { - log.info("OPTIONS 요청 수신 (CORS preflight)"); + log.info("=== OPTIONS 요청 수신 (CORS preflight) ==="); + log.info("=== OPTIONS 응답 전송 (HTTP 200 OK) ==="); return ResponseEntity.ok().build(); } @@ -57,21 +70,45 @@ public class McpController { */ @PostMapping public ResponseEntity handleMcpRequest(@RequestBody McpJsonRpcRequest request) { - log.info("MCP 요청 수신: method={}, id={}", request.getMethod(), request.getId()); + // 요청 전체 로깅 + try { + String requestJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(request); + log.info("=== MCP 요청 수신 ===\n{}", requestJson); + } catch (JsonProcessingException e) { + log.warn("요청 JSON 변환 실패: {}", e.getMessage()); + log.info("=== MCP 요청 수신 ===\nmethod={}, id={}, request={}", + request.getMethod(), request.getId(), request); + } try { Object result = processRequest(request); + // initialized 알림은 응답이 필요 없음 (notification) + if ("initialized".equals(request.getMethod()) && request.getId() == null) { + log.info("=== initialized 알림 처리 완료 (응답 불필요) ==="); + return ResponseEntity.ok().build(); + } + McpJsonRpcResponse response = McpJsonRpcResponse.builder() .jsonrpc("2.0") .id(request.getId()) .result(result) .build(); + // 응답 전체 로깅 + try { + String responseJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(response); + log.info("=== MCP 응답 전송 ===\n{}", responseJson); + } catch (JsonProcessingException e) { + log.warn("응답 JSON 변환 실패: {}", e.getMessage()); + log.info("=== MCP 응답 전송 ===\nmethod={}, id={}, result={}", + request.getMethod(), request.getId(), result); + } + return ResponseEntity.ok(response); } catch (Exception e) { - log.error("MCP 요청 처리 중 오류 발생", e); + log.error("=== MCP 요청 처리 중 오류 발생 ===", e); McpJsonRpcResponse.McpError error = McpJsonRpcResponse.McpError.builder() .code(-32603) // Internal error @@ -85,6 +122,16 @@ public class McpController { .error(error) .build(); + // 에러 응답 로깅 + try { + String responseJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(response); + log.error("=== MCP 에러 응답 전송 ===\n{}", responseJson); + } catch (JsonProcessingException ex) { + log.warn("에러 응답 JSON 변환 실패: {}", ex.getMessage()); + log.error("=== MCP 에러 응답 전송 ===\nmethod={}, id={}, error={}", + request.getMethod(), request.getId(), error); + } + return ResponseEntity.ok(response); } } @@ -99,6 +146,11 @@ public class McpController { return switch (method) { case "initialize" -> mcpService.initialize(params); + case "initialized" -> { + // MCP 프로토콜: initialize 후 클라이언트가 보내는 알림 + log.info("MCP initialized 알림 수신"); + yield Map.of("status", "ok"); + } case "tools/list" -> mcpService.listTools(); case "tools/call" -> { Object args = params != null ? params.getArguments() : null; diff --git a/src/main/java/com/pandol365/dewey/api/dto/response/McpJsonRpcResponse.java b/src/main/java/com/pandol365/dewey/api/dto/response/McpJsonRpcResponse.java index 92f69e4..dbe37e7 100644 --- a/src/main/java/com/pandol365/dewey/api/dto/response/McpJsonRpcResponse.java +++ b/src/main/java/com/pandol365/dewey/api/dto/response/McpJsonRpcResponse.java @@ -1,5 +1,6 @@ package com.pandol365.dewey.api.dto.response; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; @@ -13,13 +14,14 @@ import lombok.NoArgsConstructor; @Builder @NoArgsConstructor @AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) public class McpJsonRpcResponse { @JsonProperty("jsonrpc") @Builder.Default private String jsonrpc = "2.0"; - private String id; + private Object id; private Object result; diff --git a/src/main/java/com/pandol365/dewey/api/service/impl/McpServiceImpl.java b/src/main/java/com/pandol365/dewey/api/service/impl/McpServiceImpl.java index c25ae04..5df1fcc 100644 --- a/src/main/java/com/pandol365/dewey/api/service/impl/McpServiceImpl.java +++ b/src/main/java/com/pandol365/dewey/api/service/impl/McpServiceImpl.java @@ -29,18 +29,41 @@ public class McpServiceImpl implements McpService { public McpInitializeResponse initialize(McpJsonRpcRequest.McpParams params) { log.info("MCP 서버 초기화 요청: {}", params); + // Cursor가 보낸 프로토콜 버전 사용 (호환성) + String protocolVersion = params != null && params.getProtocolVersion() != null + ? params.getProtocolVersion() + : PROTOCOL_VERSION; + + // MCP 표준 capabilities 형식 - Cursor가 tools/list를 호출하도록 설정 Map capabilities = new HashMap<>(); - capabilities.put("tools", Map.of()); - capabilities.put("resources", Map.of()); - capabilities.put("prompts", Map.of()); + + // tools capability: listChanged를 true로 설정하여 Cursor가 tools/list를 호출하도록 유도 + Map toolsCapability = new HashMap<>(); + toolsCapability.put("listChanged", true); + toolsCapability.put("supported", true); // tools 지원 + capabilities.put("tools", toolsCapability); + + // resources capability + Map resourcesCapability = new HashMap<>(); + resourcesCapability.put("listChanged", false); + resourcesCapability.put("supported", true); // resources 지원 + capabilities.put("resources", resourcesCapability); + + // prompts capability + Map promptsCapability = new HashMap<>(); + promptsCapability.put("listChanged", false); + promptsCapability.put("supported", false); // prompts 미지원 + capabilities.put("prompts", promptsCapability); McpInitializeResponse.ServerInfo serverInfo = McpInitializeResponse.ServerInfo.builder() .name(SERVER_NAME) .version(SERVER_VERSION) .build(); + log.info("MCP 초기화 응답: protocolVersion={}, capabilities={}", protocolVersion, capabilities); + return McpInitializeResponse.builder() - .protocolVersion(PROTOCOL_VERSION) + .protocolVersion(protocolVersion) .capabilities(capabilities) .serverInfo(serverInfo) .build();