From dc8015a3bfdcb3d806859f5e0fc7e365c46d7e7e Mon Sep 17 00:00:00 2001 From: mskim Date: Wed, 10 Dec 2025 11:09:08 +0900 Subject: [PATCH] =?UTF-8?q?MVC=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API명세서.md | 863 ++++++++++++++++++ .../com/pandol365/dewey/DeweyApplication.java | 5 +- .../api/dto/request/McpJsonRpcRequest.java | 44 + .../api/dto/request/MemorySearchRequest.java | 28 + .../api/dto/request/MemoryStoreRequest.java | 30 + .../dto/response/McpInitializeResponse.java | 34 + .../api/dto/response/McpJsonRpcResponse.java | 38 + .../api/dto/response/McpPromptResponse.java | 63 ++ .../api/dto/response/McpResourceResponse.java | 47 + .../api/dto/response/McpToolCallResponse.java | 30 + .../api/dto/response/McpToolResponse.java | 36 + .../api/dto/response/MemoryResponse.java | 33 + .../dewey/api/request/McpRequest.java | 40 + .../dewey/api/response/McpHealthResponse.java | 19 - .../dewey/api/response/McpInfoResponse.java | 18 - .../api/response/McpInitializeResult.java | 34 + .../dewey/api/response/McpPrompt.java | 63 ++ .../dewey/api/response/McpResource.java | 47 + .../dewey/api/response/McpResponse.java | 38 + .../pandol365/dewey/api/response/McpTool.java | 36 + .../dewey/api/response/McpToolCallResult.java | 30 + .../dewey/api/service/McpService.java | 46 + .../api/service/impl/McpServiceImpl.java | 293 ++++++ .../com/pandol365/dewey/config/McpConfig.java | 26 + .../dewey/domain/memory/model/Memory.java | 50 + .../domain/memory/model/TemporaryMemory.java | 33 + .../memory/repository/MemoryRepository.java | 35 + .../domain/memory/service/MemoryService.java | 43 + .../service/impl/MemoryServiceImpl.java | 93 ++ .../exception/GlobalExceptionHandler.java | 85 ++ src/main/resources/application.properties | 16 + 31 files changed, 2258 insertions(+), 38 deletions(-) create mode 100644 API명세서.md create mode 100644 src/main/java/com/pandol365/dewey/api/dto/request/McpJsonRpcRequest.java create mode 100644 src/main/java/com/pandol365/dewey/api/dto/request/MemorySearchRequest.java create mode 100644 src/main/java/com/pandol365/dewey/api/dto/request/MemoryStoreRequest.java create mode 100644 src/main/java/com/pandol365/dewey/api/dto/response/McpInitializeResponse.java create mode 100644 src/main/java/com/pandol365/dewey/api/dto/response/McpJsonRpcResponse.java create mode 100644 src/main/java/com/pandol365/dewey/api/dto/response/McpPromptResponse.java create mode 100644 src/main/java/com/pandol365/dewey/api/dto/response/McpResourceResponse.java create mode 100644 src/main/java/com/pandol365/dewey/api/dto/response/McpToolCallResponse.java create mode 100644 src/main/java/com/pandol365/dewey/api/dto/response/McpToolResponse.java create mode 100644 src/main/java/com/pandol365/dewey/api/dto/response/MemoryResponse.java create mode 100644 src/main/java/com/pandol365/dewey/api/request/McpRequest.java delete mode 100644 src/main/java/com/pandol365/dewey/api/response/McpHealthResponse.java delete mode 100644 src/main/java/com/pandol365/dewey/api/response/McpInfoResponse.java create mode 100644 src/main/java/com/pandol365/dewey/api/response/McpInitializeResult.java create mode 100644 src/main/java/com/pandol365/dewey/api/response/McpPrompt.java create mode 100644 src/main/java/com/pandol365/dewey/api/response/McpResource.java create mode 100644 src/main/java/com/pandol365/dewey/api/response/McpResponse.java create mode 100644 src/main/java/com/pandol365/dewey/api/response/McpTool.java create mode 100644 src/main/java/com/pandol365/dewey/api/response/McpToolCallResult.java create mode 100644 src/main/java/com/pandol365/dewey/api/service/McpService.java create mode 100644 src/main/java/com/pandol365/dewey/api/service/impl/McpServiceImpl.java create mode 100644 src/main/java/com/pandol365/dewey/config/McpConfig.java create mode 100644 src/main/java/com/pandol365/dewey/domain/memory/model/Memory.java create mode 100644 src/main/java/com/pandol365/dewey/domain/memory/model/TemporaryMemory.java create mode 100644 src/main/java/com/pandol365/dewey/domain/memory/repository/MemoryRepository.java create mode 100644 src/main/java/com/pandol365/dewey/domain/memory/service/MemoryService.java create mode 100644 src/main/java/com/pandol365/dewey/domain/memory/service/impl/MemoryServiceImpl.java create mode 100644 src/main/java/com/pandol365/dewey/exception/GlobalExceptionHandler.java diff --git a/API명세서.md b/API명세서.md new file mode 100644 index 0000000..453fd58 --- /dev/null +++ b/API명세서.md @@ -0,0 +1,863 @@ +# Dewey MCP 서버 API 명세서 + +## 개요 + +Dewey MCP (Model Context Protocol) 서버는 JSON-RPC 2.0 프로토콜을 기반으로 AI 장기기억 시스템의 기능을 제공합니다. + +- **프로토콜 버전**: 2024-11-05 +- **서버 이름**: Dewey Memory Server +- **서버 버전**: 0.0.1-SNAPSHOT +- **Base URL**: `http://localhost:8080/mcp` +- **아키텍처**: MVC (Model-View-Controller) 패턴 + +--- + +## 아키텍처 + +### MVC 구조 + +``` +Controller (McpController) + ↓ +API Service (McpService) + ↓ +Domain Service (MemoryService) + ↓ +Repository (MemoryRepository) + ↓ +Database (PostgreSQL / Redis) +``` + +### 계층별 역할 + +- **Controller**: HTTP 요청 처리 및 라우팅 +- **API Service**: Controller와 Domain Service 사이의 어댑터 +- **Domain Service**: 비즈니스 로직 처리 +- **Repository**: 데이터 접근 계층 +- **Model**: 도메인 엔티티 및 DTO + +--- + +## 엔드포인트 + +### POST /mcp + +모든 MCP 요청은 이 단일 엔드포인트로 처리됩니다. 요청 본문은 JSON-RPC 2.0 형식을 따릅니다. + +**요청 헤더:** +``` +Content-Type: application/json +``` + +--- + +## MCP 메서드 + +### 1. initialize + +MCP 서버를 초기화하고 서버 정보 및 지원 기능을 반환합니다. + +#### 요청 + +```json +{ + "jsonrpc": "2.0", + "id": "1", + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { + "name": "client-name", + "version": "1.0.0" + } + } +} +``` + +#### 응답 + +```json +{ + "jsonrpc": "2.0", + "id": "1", + "result": { + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {}, + "resources": {}, + "prompts": {} + }, + "serverInfo": { + "name": "Dewey Memory Server", + "version": "0.0.1-SNAPSHOT" + } + } +} +``` + +**응답 DTO**: `McpInitializeResponse` + +--- + +### 2. tools/list + +사용 가능한 모든 도구 목록을 반환합니다. + +#### 요청 + +```json +{ + "jsonrpc": "2.0", + "id": "2", + "method": "tools/list" +} +``` + +#### 응답 + +```json +{ + "jsonrpc": "2.0", + "id": "2", + "result": { + "tools": [ + { + "name": "store_memory", + "description": "메모리를 Redis에 임시 저장합니다", + "inputSchema": { + "type": "object", + "properties": { + "memory_text": { + "type": "string", + "description": "저장할 메모리 텍스트" + }, + "user_id": { + "type": "string", + "description": "사용자 ID" + }, + "importance": { + "type": "integer", + "description": "중요도 (1-5)", + "minimum": 1, + "maximum": 5 + } + }, + "required": ["memory_text", "user_id"] + } + }, + { + "name": "retrieve_memory", + "description": "사용자의 메모리를 조회합니다", + "inputSchema": { + "type": "object", + "properties": { + "user_id": { + "type": "string", + "description": "사용자 ID" + }, + "limit": { + "type": "integer", + "description": "조회할 메모리 개수", + "default": 10 + } + }, + "required": ["user_id"] + } + }, + { + "name": "search_memory", + "description": "벡터 유사도 기반으로 메모리를 검색합니다", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "검색 쿼리" + }, + "user_id": { + "type": "string", + "description": "사용자 ID (선택)" + }, + "limit": { + "type": "integer", + "description": "검색 결과 개수", + "default": 5 + } + }, + "required": ["query"] + } + } + ] + } +} +``` + +**응답 DTO**: `McpToolResponse.ToolListResponse` + +--- + +### 3. tools/call + +특정 도구를 실행합니다. 도구 실행 결과에 따라 응답 내용이 달라집니다. + +#### 요청 형식 + +```json +{ + "jsonrpc": "2.0", + "id": "3", + "method": "tools/call", + "params": { + "arguments": { + "name": "도구명", + "arguments": { + "도구별_파라미터": "값" + } + } + } +} +``` + +#### 응답 형식 + +```json +{ + "jsonrpc": "2.0", + "id": "3", + "result": { + "isError": false, + "content": "실행 결과 메시지", + "parts": [], + "metadata": { + "tool": "도구명", + "timestamp": 1701234567890 + } + } +} +``` + +**응답 DTO**: `McpToolCallResponse` + +#### 도구별 사용 예시 + +##### store_memory + +메모리를 Redis에 임시 저장합니다. + +**요청:** +```json +{ + "jsonrpc": "2.0", + "id": "4", + "method": "tools/call", + "params": { + "arguments": { + "name": "store_memory", + "arguments": { + "memory_text": "오늘 사용자가 피자를 주문했습니다", + "user_id": "user123", + "importance": 4 + } + } + } +} +``` + +**성공 응답:** +```json +{ + "jsonrpc": "2.0", + "id": "4", + "result": { + "isError": false, + "content": "Memory stored successfully", + "parts": [], + "metadata": { + "tool": "store_memory", + "timestamp": 1701234567890 + } + } +} +``` + +**비즈니스 로직**: `MemoryService.storeTemporaryMemory()` 호출 + +--- + +##### retrieve_memory + +사용자의 영구 메모리를 PostgreSQL에서 조회합니다. + +**요청:** +```json +{ + "jsonrpc": "2.0", + "id": "5", + "method": "tools/call", + "params": { + "arguments": { + "name": "retrieve_memory", + "arguments": { + "user_id": "user123", + "limit": 10 + } + } + } +} +``` + +**성공 응답:** +```json +{ + "jsonrpc": "2.0", + "id": "5", + "result": { + "isError": false, + "content": "Retrieved 10 memories", + "parts": [], + "metadata": { + "tool": "retrieve_memory", + "timestamp": 1701234567890 + } + } +} +``` + +**비즈니스 로직**: `MemoryService.getPermanentMemories()` 호출 + +--- + +##### search_memory + +벡터 유사도 기반으로 메모리를 검색합니다. + +**요청:** +```json +{ + "jsonrpc": "2.0", + "id": "6", + "method": "tools/call", + "params": { + "arguments": { + "name": "search_memory", + "arguments": { + "query": "커피", + "user_id": "user123", + "limit": 5 + } + } + } +} +``` + +**성공 응답:** +```json +{ + "jsonrpc": "2.0", + "id": "6", + "result": { + "isError": false, + "content": "Found 3 matching memories", + "parts": [], + "metadata": { + "tool": "search_memory", + "timestamp": 1701234567890 + } + } +} +``` + +**비즈니스 로직**: `MemoryService.searchMemoriesByVector()` 호출 + +**에러 응답 예시:** +```json +{ + "jsonrpc": "2.0", + "id": "6", + "result": { + "isError": true, + "content": "Error: Unknown tool: invalid_tool", + "parts": [], + "metadata": { + "tool": "invalid_tool", + "timestamp": 1701234567890 + } + } +} +``` + +--- + +### 4. resources/list + +사용 가능한 모든 리소스 목록을 반환합니다. + +#### 요청 + +```json +{ + "jsonrpc": "2.0", + "id": "7", + "method": "resources/list" +} +``` + +#### 응답 + +```json +{ + "jsonrpc": "2.0", + "id": "7", + "result": { + "resources": [ + { + "uri": "memory://recent", + "name": "Recent Memories", + "description": "최근 저장된 메모리 리소스", + "mimeType": "application/json" + }, + { + "uri": "memory://long-term", + "name": "Long-term Memories", + "description": "장기 저장된 메모리 리소스", + "mimeType": "application/json" + } + ] + } +} +``` + +**응답 DTO**: `McpResourceResponse.ResourceListResponse` + +--- + +### 5. resources/read + +특정 리소스의 내용을 읽습니다. + +#### 요청 + +```json +{ + "jsonrpc": "2.0", + "id": "8", + "method": "resources/read", + "params": { + "uri": "memory://recent" + } +} +``` + +또는 + +```json +{ + "jsonrpc": "2.0", + "id": "8", + "method": "resources/read", + "params": { + "arguments": { + "uri": "memory://recent" + } + } +} +``` + +#### 응답 + +```json +{ + "jsonrpc": "2.0", + "id": "8", + "result": { + "uri": "memory://recent", + "mimeType": "application/json", + "text": "{\"message\": \"Resource content for memory://recent\"}", + "metadata": { + "uri": "memory://recent", + "timestamp": 1701234567890 + } + } +} +``` + +**응답 DTO**: `McpResourceResponse.ResourceReadResponse` + +--- + +### 6. prompts/list + +사용 가능한 모든 프롬프트 목록을 반환합니다. + +#### 요청 + +```json +{ + "jsonrpc": "2.0", + "id": "9", + "method": "prompts/list" +} +``` + +#### 응답 + +```json +{ + "jsonrpc": "2.0", + "id": "9", + "result": { + "prompts": [ + { + "name": "memory_summary", + "description": "메모리 목록을 요약하는 프롬프트", + "arguments": [ + { + "name": "memories", + "description": "요약할 메모리 텍스트 목록", + "required": true + } + ] + }, + { + "name": "memory_search", + "description": "메모리 검색을 위한 프롬프트", + "arguments": [ + { + "name": "query", + "description": "검색할 키워드", + "required": true + } + ] + } + ] + } +} +``` + +**응답 DTO**: `McpPromptResponse.PromptListResponse` + +--- + +### 7. prompts/get + +특정 프롬프트를 가져옵니다. + +#### 요청 + +```json +{ + "jsonrpc": "2.0", + "id": "10", + "method": "prompts/get", + "params": { + "arguments": { + "name": "memory_summary", + "arguments": { + "memories": ["메모리1", "메모리2", "메모리3"] + } + } + } +} +``` + +#### 응답 + +```json +{ + "jsonrpc": "2.0", + "id": "10", + "result": { + "name": "memory_summary", + "messages": [ + { + "role": "user", + "content": "Prompt: memory_summary" + } + ], + "arguments": { + "memories": ["메모리1", "메모리2", "메모리3"] + } + } +} +``` + +**응답 DTO**: `McpPromptResponse.PromptGetResponse` + +--- + +## 에러 응답 + +에러가 발생한 경우 다음과 같은 형식으로 응답됩니다: + +```json +{ + "jsonrpc": "2.0", + "id": "1", + "error": { + "code": -32603, + "message": "Internal error", + "data": null + } +} +``` + +### 에러 코드 + +| 코드 | 의미 | 설명 | +|------|------|------| +| `-32600` | Invalid Request | 요청 형식이 잘못됨 | +| `-32601` | Method not found | 존재하지 않는 메서드 호출 | +| `-32602` | Invalid params | 파라미터가 잘못됨 | +| `-32603` | Internal error | 서버 내부 오류 | +| `-32700` | Parse error | JSON 파싱 오류 | + +**에러 처리**: `GlobalExceptionHandler`에서 전역 예외 처리 + +--- + +## 사용 예시 + +### cURL 예시 + +#### initialize + +```bash +curl -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": "1", + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { + "name": "test-client", + "version": "1.0.0" + } + } + }' +``` + +#### tools/list + +```bash +curl -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": "2", + "method": "tools/list" + }' +``` + +#### tools/call - store_memory + +```bash +curl -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": "3", + "method": "tools/call", + "params": { + "arguments": { + "name": "store_memory", + "arguments": { + "memory_text": "사용자는 파이썬을 좋아합니다", + "user_id": "user123", + "importance": 4 + } + } + } + }' +``` + +#### tools/call - retrieve_memory + +```bash +curl -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": "4", + "method": "tools/call", + "params": { + "arguments": { + "name": "retrieve_memory", + "arguments": { + "user_id": "user123", + "limit": 10 + } + } + } + }' +``` + +#### tools/call - search_memory + +```bash +curl -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": "5", + "method": "tools/call", + "params": { + "arguments": { + "name": "search_memory", + "arguments": { + "query": "커피", + "user_id": "user123", + "limit": 5 + } + } + } + }' +``` + +--- + +## 데이터 모델 + +### Memory (도메인 엔티티) + +PostgreSQL에 저장되는 영구 메모리 엔티티입니다. + +```java +@Entity +@Table(name = "memories") +public class Memory { + private Long id; + private String userId; + private String memoryText; + private Integer importance; // 1~5 + private String tags; // JSON 형식 + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} +``` + +### TemporaryMemory (도메인 모델) + +Redis에 저장되는 임시 메모리 모델입니다. + +```java +public class TemporaryMemory { + private String id; // Redis key + private String userId; + private String memoryText; + private Integer importance; + private LocalDateTime createdAt; + private LocalDateTime expiresAt; // TTL 기반 +} +``` + +### MemoryResponse (응답 DTO) + +```java +public class MemoryResponse { + private Long id; + private String userId; + private String memoryText; + private Integer importance; + private String tags; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} +``` + +--- + +## 참고사항 + +### 1. 요청/응답 형식 + +- 모든 요청은 `Content-Type: application/json` 헤더가 필요합니다. +- `id` 필드는 요청과 응답을 매칭하기 위해 사용됩니다. +- 모든 응답은 `jsonrpc: "2.0"` 필드를 포함합니다. + +### 2. 아키텍처 + +- **MVC 패턴**을 따르며, 각 계층이 명확히 분리되어 있습니다. +- **Controller**는 요청 처리만 담당합니다. +- **API Service**는 Controller와 Domain Service 사이의 어댑터 역할을 합니다. +- **Domain Service**는 비즈니스 로직을 처리합니다. +- **Repository**는 데이터 접근만 담당합니다. + +### 3. 데이터 저장 + +- **임시 메모리**: Redis에 저장 (TTL 3일) +- **영구 메모리**: PostgreSQL에 저장 +- **벡터 검색**: PostgreSQL의 pgvector 확장 사용 (향후 구현) + +### 4. CORS 설정 + +- CORS는 모든 origin에서 허용되도록 설정되어 있습니다 (`/mcp/**`). +- 프로덕션 환경에서는 특정 origin만 허용하도록 변경하는 것을 권장합니다. + +### 5. 예외 처리 + +- 전역 예외 처리는 `GlobalExceptionHandler`에서 수행됩니다. +- 유효성 검증 오류는 `MethodArgumentNotValidException`으로 처리됩니다. +- 모든 예외는 JSON-RPC 2.0 에러 형식으로 변환됩니다. + +### 6. 구현 상태 + +- ✅ 기본 MCP 프로토콜 구현 완료 +- ✅ 도메인 모델 및 Repository 생성 완료 +- ✅ MVC 구조 재설계 완료 +- ⏳ Redis 연동 (TODO) +- ⏳ 벡터 검색 구현 (TODO) +- ⏳ 배치 처리 구현 (TODO) + +--- + +## 버전 정보 + +- **문서 버전**: 2.0.0 +- **최종 업데이트**: 2025-12-10 +- **서버 버전**: 0.0.1-SNAPSHOT +- **아키텍처**: MVC (Model-View-Controller) + +--- + +## 관련 파일 구조 + +``` +src/main/java/com/pandol365/dewey/ +├── api/ +│ ├── controller/ +│ │ └── McpController.java # Controller 계층 +│ ├── dto/ +│ │ ├── request/ # 요청 DTO +│ │ │ ├── McpJsonRpcRequest.java +│ │ │ ├── MemoryStoreRequest.java +│ │ │ └── MemorySearchRequest.java +│ │ └── response/ # 응답 DTO (View) +│ │ ├── McpJsonRpcResponse.java +│ │ ├── McpInitializeResponse.java +│ │ ├── McpToolResponse.java +│ │ ├── McpResourceResponse.java +│ │ ├── McpPromptResponse.java +│ │ ├── McpToolCallResponse.java +│ │ └── MemoryResponse.java +│ └── service/ # API Service 계층 +│ ├── McpService.java +│ └── impl/ +│ └── McpServiceImpl.java +├── domain/ +│ └── memory/ +│ ├── model/ # 도메인 모델 (Model) +│ │ ├── Memory.java +│ │ └── TemporaryMemory.java +│ ├── repository/ # Repository 계층 +│ │ └── MemoryRepository.java +│ └── service/ # Domain Service 계층 +│ ├── MemoryService.java +│ └── impl/ +│ └── MemoryServiceImpl.java +└── exception/ + └── GlobalExceptionHandler.java # 예외 처리 +``` diff --git a/src/main/java/com/pandol365/dewey/DeweyApplication.java b/src/main/java/com/pandol365/dewey/DeweyApplication.java index e0855c3..8bc5fe8 100644 --- a/src/main/java/com/pandol365/dewey/DeweyApplication.java +++ b/src/main/java/com/pandol365/dewey/DeweyApplication.java @@ -2,8 +2,11 @@ package com.pandol365.dewey; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.ai.vectorstore.pgvector.autoconfigure.PgVectorStoreAutoConfiguration; -@SpringBootApplication +@SpringBootApplication(exclude = { + PgVectorStoreAutoConfiguration.class +}) public class DeweyApplication { public static void main(String[] args) { SpringApplication.run(DeweyApplication.class, args); diff --git a/src/main/java/com/pandol365/dewey/api/dto/request/McpJsonRpcRequest.java b/src/main/java/com/pandol365/dewey/api/dto/request/McpJsonRpcRequest.java new file mode 100644 index 0000000..1944d95 --- /dev/null +++ b/src/main/java/com/pandol365/dewey/api/dto/request/McpJsonRpcRequest.java @@ -0,0 +1,44 @@ +package com.pandol365.dewey.api.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * MCP JSON-RPC 2.0 요청 DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class McpJsonRpcRequest { + + @JsonProperty("jsonrpc") + @Builder.Default + private String jsonrpc = "2.0"; + + private String id; + + private String method; + + private McpParams params; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class McpParams { + private String protocolVersion; + private Map capabilities; + private Map clientInfo; + private String name; + private String uri; + private String prompt; + private Object arguments; + } +} + diff --git a/src/main/java/com/pandol365/dewey/api/dto/request/MemorySearchRequest.java b/src/main/java/com/pandol365/dewey/api/dto/request/MemorySearchRequest.java new file mode 100644 index 0000000..d4b6bcf --- /dev/null +++ b/src/main/java/com/pandol365/dewey/api/dto/request/MemorySearchRequest.java @@ -0,0 +1,28 @@ +package com.pandol365.dewey.api.dto.request; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 메모리 검색 요청 DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemorySearchRequest { + + @NotBlank(message = "검색 쿼리는 필수입니다") + private String query; + + private String userId; + + @Min(value = 1, message = "limit은 1 이상이어야 합니다") + @Builder.Default + private Integer limit = 5; +} + diff --git a/src/main/java/com/pandol365/dewey/api/dto/request/MemoryStoreRequest.java b/src/main/java/com/pandol365/dewey/api/dto/request/MemoryStoreRequest.java new file mode 100644 index 0000000..dac67bb --- /dev/null +++ b/src/main/java/com/pandol365/dewey/api/dto/request/MemoryStoreRequest.java @@ -0,0 +1,30 @@ +package com.pandol365.dewey.api.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 메모리 저장 요청 DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemoryStoreRequest { + + @NotBlank(message = "메모리 텍스트는 필수입니다") + private String memoryText; + + @NotBlank(message = "사용자 ID는 필수입니다") + private String userId; + + @Min(value = 1, message = "중요도는 1 이상이어야 합니다") + @Max(value = 5, message = "중요도는 5 이하여야 합니다") + private Integer importance; +} + diff --git a/src/main/java/com/pandol365/dewey/api/dto/response/McpInitializeResponse.java b/src/main/java/com/pandol365/dewey/api/dto/response/McpInitializeResponse.java new file mode 100644 index 0000000..4b02f8d --- /dev/null +++ b/src/main/java/com/pandol365/dewey/api/dto/response/McpInitializeResponse.java @@ -0,0 +1,34 @@ +package com.pandol365.dewey.api.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * MCP initialize 메서드 응답 DTO (View) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class McpInitializeResponse { + + private String protocolVersion; + + private Map capabilities; + + private ServerInfo serverInfo; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ServerInfo { + private String name; + private String version; + } +} + 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 new file mode 100644 index 0000000..92f69e4 --- /dev/null +++ b/src/main/java/com/pandol365/dewey/api/dto/response/McpJsonRpcResponse.java @@ -0,0 +1,38 @@ +package com.pandol365.dewey.api.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * MCP JSON-RPC 2.0 응답 DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class McpJsonRpcResponse { + + @JsonProperty("jsonrpc") + @Builder.Default + private String jsonrpc = "2.0"; + + private String id; + + private Object result; + + private McpError error; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class McpError { + private Integer code; + private String message; + private Object data; + } +} + diff --git a/src/main/java/com/pandol365/dewey/api/dto/response/McpPromptResponse.java b/src/main/java/com/pandol365/dewey/api/dto/response/McpPromptResponse.java new file mode 100644 index 0000000..a320fea --- /dev/null +++ b/src/main/java/com/pandol365/dewey/api/dto/response/McpPromptResponse.java @@ -0,0 +1,63 @@ +package com.pandol365.dewey.api.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * MCP Prompt 응답 DTO (View) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class McpPromptResponse { + + private String name; + + private String description; + + private List arguments; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class PromptArgument { + private String name; + private String description; + private Boolean required; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class PromptListResponse { + private List prompts; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class PromptGetResponse { + private String name; + private List messages; + private Map arguments; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class PromptMessage { + private String role; + private Object content; + } +} + diff --git a/src/main/java/com/pandol365/dewey/api/dto/response/McpResourceResponse.java b/src/main/java/com/pandol365/dewey/api/dto/response/McpResourceResponse.java new file mode 100644 index 0000000..173d952 --- /dev/null +++ b/src/main/java/com/pandol365/dewey/api/dto/response/McpResourceResponse.java @@ -0,0 +1,47 @@ +package com.pandol365.dewey.api.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * MCP Resource 응답 DTO (View) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class McpResourceResponse { + + private String uri; + + private String name; + + private String description; + + private String mimeType; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ResourceListResponse { + private List resources; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ResourceReadResponse { + private String uri; + private String mimeType; + private String text; + private Map metadata; + } +} + diff --git a/src/main/java/com/pandol365/dewey/api/dto/response/McpToolCallResponse.java b/src/main/java/com/pandol365/dewey/api/dto/response/McpToolCallResponse.java new file mode 100644 index 0000000..ed83ac9 --- /dev/null +++ b/src/main/java/com/pandol365/dewey/api/dto/response/McpToolCallResponse.java @@ -0,0 +1,30 @@ +package com.pandol365.dewey.api.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * MCP tools/call 메서드 응답 DTO (View) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class McpToolCallResponse { + + @JsonProperty("isError") + private Boolean isError; + + private String content; + + private List> parts; + + private Map metadata; +} + diff --git a/src/main/java/com/pandol365/dewey/api/dto/response/McpToolResponse.java b/src/main/java/com/pandol365/dewey/api/dto/response/McpToolResponse.java new file mode 100644 index 0000000..06c7450 --- /dev/null +++ b/src/main/java/com/pandol365/dewey/api/dto/response/McpToolResponse.java @@ -0,0 +1,36 @@ +package com.pandol365.dewey.api.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * MCP Tool 응답 DTO (View) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class McpToolResponse { + + private String name; + + private String description; + + @JsonProperty("inputSchema") + private Map inputSchema; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ToolListResponse { + private List tools; + } +} + diff --git a/src/main/java/com/pandol365/dewey/api/dto/response/MemoryResponse.java b/src/main/java/com/pandol365/dewey/api/dto/response/MemoryResponse.java new file mode 100644 index 0000000..c791730 --- /dev/null +++ b/src/main/java/com/pandol365/dewey/api/dto/response/MemoryResponse.java @@ -0,0 +1,33 @@ +package com.pandol365.dewey.api.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 메모리 응답 DTO (View) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemoryResponse { + + private Long id; + + private String userId; + + private String memoryText; + + private Integer importance; + + private String tags; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} + diff --git a/src/main/java/com/pandol365/dewey/api/request/McpRequest.java b/src/main/java/com/pandol365/dewey/api/request/McpRequest.java new file mode 100644 index 0000000..84d381f --- /dev/null +++ b/src/main/java/com/pandol365/dewey/api/request/McpRequest.java @@ -0,0 +1,40 @@ +package com.pandol365.dewey.api.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * MCP JSON-RPC 2.0 Request DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class McpRequest { + + @JsonProperty("jsonrpc") + @Builder.Default + private String jsonrpc = "2.0"; + + private String id; + + private String method; + + private McpParams params; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class McpParams { + private String name; + private String version; + private Object arguments; + private String uri; + private String prompt; + } +} + diff --git a/src/main/java/com/pandol365/dewey/api/response/McpHealthResponse.java b/src/main/java/com/pandol365/dewey/api/response/McpHealthResponse.java deleted file mode 100644 index e578c91..0000000 --- a/src/main/java/com/pandol365/dewey/api/response/McpHealthResponse.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.pandol365.dewey.api.response; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class McpHealthResponse { - private String status; - private LocalDateTime timestamp; -} - - diff --git a/src/main/java/com/pandol365/dewey/api/response/McpInfoResponse.java b/src/main/java/com/pandol365/dewey/api/response/McpInfoResponse.java deleted file mode 100644 index caf10e4..0000000 --- a/src/main/java/com/pandol365/dewey/api/response/McpInfoResponse.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.pandol365.dewey.api.response; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class McpInfoResponse { - private String name; - private String version; - private String description; -} - - diff --git a/src/main/java/com/pandol365/dewey/api/response/McpInitializeResult.java b/src/main/java/com/pandol365/dewey/api/response/McpInitializeResult.java new file mode 100644 index 0000000..a6b9199 --- /dev/null +++ b/src/main/java/com/pandol365/dewey/api/response/McpInitializeResult.java @@ -0,0 +1,34 @@ +package com.pandol365.dewey.api.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * MCP initialize 메서드 응답 결과 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class McpInitializeResult { + + private String protocolVersion; + + private Map capabilities; + + private ServerInfo serverInfo; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ServerInfo { + private String name; + private String version; + } +} + diff --git a/src/main/java/com/pandol365/dewey/api/response/McpPrompt.java b/src/main/java/com/pandol365/dewey/api/response/McpPrompt.java new file mode 100644 index 0000000..b23fec7 --- /dev/null +++ b/src/main/java/com/pandol365/dewey/api/response/McpPrompt.java @@ -0,0 +1,63 @@ +package com.pandol365.dewey.api.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * MCP Prompt 정의 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class McpPrompt { + + private String name; + + private String description; + + private List arguments; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class PromptArgument { + private String name; + private String description; + private Boolean required; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class PromptListResult { + private List prompts; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class PromptGetResult { + private String name; + private List messages; + private Map arguments; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class PromptMessage { + private String role; + private Object content; + } +} + diff --git a/src/main/java/com/pandol365/dewey/api/response/McpResource.java b/src/main/java/com/pandol365/dewey/api/response/McpResource.java new file mode 100644 index 0000000..c4f2ac7 --- /dev/null +++ b/src/main/java/com/pandol365/dewey/api/response/McpResource.java @@ -0,0 +1,47 @@ +package com.pandol365.dewey.api.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * MCP Resource 정의 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class McpResource { + + private String uri; + + private String name; + + private String description; + + private String mimeType; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ResourceListResult { + private List resources; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ResourceReadResult { + private String uri; + private String mimeType; + private String text; + private Map metadata; + } +} + diff --git a/src/main/java/com/pandol365/dewey/api/response/McpResponse.java b/src/main/java/com/pandol365/dewey/api/response/McpResponse.java new file mode 100644 index 0000000..a8224cb --- /dev/null +++ b/src/main/java/com/pandol365/dewey/api/response/McpResponse.java @@ -0,0 +1,38 @@ +package com.pandol365.dewey.api.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * MCP JSON-RPC 2.0 Response DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class McpResponse { + + @JsonProperty("jsonrpc") + @Builder.Default + private String jsonrpc = "2.0"; + + private String id; + + private Object result; + + private McpError error; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class McpError { + private Integer code; + private String message; + private Object data; + } +} + diff --git a/src/main/java/com/pandol365/dewey/api/response/McpTool.java b/src/main/java/com/pandol365/dewey/api/response/McpTool.java new file mode 100644 index 0000000..775a54e --- /dev/null +++ b/src/main/java/com/pandol365/dewey/api/response/McpTool.java @@ -0,0 +1,36 @@ +package com.pandol365.dewey.api.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * MCP Tool 정의 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class McpTool { + + private String name; + + private String description; + + @JsonProperty("inputSchema") + private Map inputSchema; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ToolListResult { + private List tools; + } +} + diff --git a/src/main/java/com/pandol365/dewey/api/response/McpToolCallResult.java b/src/main/java/com/pandol365/dewey/api/response/McpToolCallResult.java new file mode 100644 index 0000000..8669731 --- /dev/null +++ b/src/main/java/com/pandol365/dewey/api/response/McpToolCallResult.java @@ -0,0 +1,30 @@ +package com.pandol365.dewey.api.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * MCP tools/call 메서드 응답 결과 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class McpToolCallResult { + + @JsonProperty("isError") + private Boolean isError; + + private String content; + + private List> parts; + + private Map metadata; +} + diff --git a/src/main/java/com/pandol365/dewey/api/service/McpService.java b/src/main/java/com/pandol365/dewey/api/service/McpService.java new file mode 100644 index 0000000..39d5107 --- /dev/null +++ b/src/main/java/com/pandol365/dewey/api/service/McpService.java @@ -0,0 +1,46 @@ +package com.pandol365.dewey.api.service; + +import com.pandol365.dewey.api.dto.request.McpJsonRpcRequest; +import com.pandol365.dewey.api.dto.response.*; + +/** + * MCP 서버 비즈니스 로직 인터페이스 + * Controller와 Domain Service 사이의 어댑터 역할 + */ +public interface McpService { + + /** + * MCP 서버 초기화 + */ + McpInitializeResponse initialize(McpJsonRpcRequest.McpParams params); + + /** + * 사용 가능한 도구 목록 반환 + */ + McpToolResponse.ToolListResponse listTools(); + + /** + * 도구 호출 실행 + */ + McpToolCallResponse callTool(String name, Object arguments); + + /** + * 사용 가능한 리소스 목록 반환 + */ + McpResourceResponse.ResourceListResponse listResources(); + + /** + * 리소스 읽기 + */ + McpResourceResponse.ResourceReadResponse readResource(String uri); + + /** + * 사용 가능한 프롬프트 목록 반환 + */ + McpPromptResponse.PromptListResponse listPrompts(); + + /** + * 프롬프트 가져오기 + */ + McpPromptResponse.PromptGetResponse getPrompt(String name, Object arguments); +} 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 new file mode 100644 index 0000000..c25ae04 --- /dev/null +++ b/src/main/java/com/pandol365/dewey/api/service/impl/McpServiceImpl.java @@ -0,0 +1,293 @@ +package com.pandol365.dewey.api.service.impl; + +import com.pandol365.dewey.api.dto.request.McpJsonRpcRequest; +import com.pandol365.dewey.api.dto.response.*; +import com.pandol365.dewey.api.service.McpService; +import com.pandol365.dewey.domain.memory.service.MemoryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.*; + +/** + * MCP 서버 구현체 + * Controller와 Domain Service 사이의 어댑터 역할 수행 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class McpServiceImpl implements McpService { + + private static final String PROTOCOL_VERSION = "2024-11-05"; + private static final String SERVER_NAME = "Dewey Memory Server"; + private static final String SERVER_VERSION = "0.0.1-SNAPSHOT"; + + private final MemoryService memoryService; + + @Override + public McpInitializeResponse initialize(McpJsonRpcRequest.McpParams params) { + log.info("MCP 서버 초기화 요청: {}", params); + + Map capabilities = new HashMap<>(); + capabilities.put("tools", Map.of()); + capabilities.put("resources", Map.of()); + capabilities.put("prompts", Map.of()); + + McpInitializeResponse.ServerInfo serverInfo = McpInitializeResponse.ServerInfo.builder() + .name(SERVER_NAME) + .version(SERVER_VERSION) + .build(); + + return McpInitializeResponse.builder() + .protocolVersion(PROTOCOL_VERSION) + .capabilities(capabilities) + .serverInfo(serverInfo) + .build(); + } + + @Override + public McpToolResponse.ToolListResponse listTools() { + log.info("MCP 도구 목록 조회"); + + List tools = new ArrayList<>(); + tools.add(createMemoryStoreTool()); + tools.add(createMemoryRetrieveTool()); + tools.add(createMemorySearchTool()); + + return McpToolResponse.ToolListResponse.builder() + .tools(tools) + .build(); + } + + @Override + public McpToolCallResponse callTool(String name, Object arguments) { + log.info("MCP 도구 호출: name={}, arguments={}", name, arguments); + + @SuppressWarnings("unchecked") + Map args = arguments instanceof Map ? (Map) arguments : new HashMap<>(); + + Map metadata = new HashMap<>(); + metadata.put("tool", name); + metadata.put("timestamp", System.currentTimeMillis()); + + try { + switch (name) { + case "store_memory" -> { + String userId = (String) args.get("user_id"); + String memoryText = (String) args.get("memory_text"); + Integer importance = args.get("importance") != null ? + ((Number) args.get("importance")).intValue() : 1; + + memoryService.storeTemporaryMemory(userId, memoryText, importance); + + return McpToolCallResponse.builder() + .isError(false) + .content("Memory stored successfully") + .parts(Collections.emptyList()) + .metadata(metadata) + .build(); + } + case "retrieve_memory" -> { + String userId = (String) args.get("user_id"); + Integer limit = args.get("limit") != null ? + ((Number) args.get("limit")).intValue() : 10; + + var memories = memoryService.getPermanentMemories(userId, limit); + + return McpToolCallResponse.builder() + .isError(false) + .content("Retrieved " + memories.size() + " memories") + .parts(Collections.emptyList()) + .metadata(metadata) + .build(); + } + case "search_memory" -> { + String query = (String) args.get("query"); + String userId = (String) args.get("user_id"); + Integer limit = args.get("limit") != null ? + ((Number) args.get("limit")).intValue() : 5; + + var memories = memoryService.searchMemoriesByVector(query, userId, limit); + + return McpToolCallResponse.builder() + .isError(false) + .content("Found " + memories.size() + " matching memories") + .parts(Collections.emptyList()) + .metadata(metadata) + .build(); + } + default -> { + return McpToolCallResponse.builder() + .isError(true) + .content("Unknown tool: " + name) + .parts(Collections.emptyList()) + .metadata(metadata) + .build(); + } + } + } catch (Exception e) { + log.error("Tool execution error: {}", e.getMessage(), e); + return McpToolCallResponse.builder() + .isError(true) + .content("Error: " + e.getMessage()) + .parts(Collections.emptyList()) + .metadata(metadata) + .build(); + } + } + + @Override + public McpResourceResponse.ResourceListResponse listResources() { + log.info("MCP 리소스 목록 조회"); + + List resources = new ArrayList<>(); + resources.add(McpResourceResponse.builder() + .uri("memory://recent") + .name("Recent Memories") + .description("최근 저장된 메모리 리소스") + .mimeType("application/json") + .build()); + resources.add(McpResourceResponse.builder() + .uri("memory://long-term") + .name("Long-term Memories") + .description("장기 저장된 메모리 리소스") + .mimeType("application/json") + .build()); + + return McpResourceResponse.ResourceListResponse.builder() + .resources(resources) + .build(); + } + + @Override + public McpResourceResponse.ResourceReadResponse readResource(String uri) { + log.info("MCP 리소스 읽기: uri={}", uri); + + Map metadata = new HashMap<>(); + metadata.put("uri", uri); + metadata.put("timestamp", System.currentTimeMillis()); + + return McpResourceResponse.ResourceReadResponse.builder() + .uri(uri) + .mimeType("application/json") + .text("{\"message\": \"Resource content for " + uri + "\"}") + .metadata(metadata) + .build(); + } + + @Override + public McpPromptResponse.PromptListResponse listPrompts() { + log.info("MCP 프롬프트 목록 조회"); + + List prompts = new ArrayList<>(); + prompts.add(createMemorySummaryPrompt()); + prompts.add(createMemorySearchPrompt()); + + return McpPromptResponse.PromptListResponse.builder() + .prompts(prompts) + .build(); + } + + @Override + public McpPromptResponse.PromptGetResponse getPrompt(String name, Object arguments) { + log.info("MCP 프롬프트 가져오기: name={}, arguments={}", name, arguments); + + List messages = new ArrayList<>(); + messages.add(McpPromptResponse.PromptMessage.builder() + .role("user") + .content("Prompt: " + name) + .build()); + + @SuppressWarnings("unchecked") + Map args = arguments instanceof Map ? (Map) arguments : new HashMap<>(); + + return McpPromptResponse.PromptGetResponse.builder() + .name(name) + .messages(messages) + .arguments(args) + .build(); + } + + // 도구 생성 헬퍼 메서드들 + private McpToolResponse createMemoryStoreTool() { + Map inputSchema = new HashMap<>(); + inputSchema.put("type", "object"); + inputSchema.put("properties", Map.of( + "memory_text", Map.of("type", "string", "description", "저장할 메모리 텍스트"), + "user_id", Map.of("type", "string", "description", "사용자 ID"), + "importance", Map.of("type", "integer", "description", "중요도 (1-5)", "minimum", 1, "maximum", 5) + )); + inputSchema.put("required", Arrays.asList("memory_text", "user_id")); + + return McpToolResponse.builder() + .name("store_memory") + .description("메모리를 Redis에 임시 저장합니다") + .inputSchema(inputSchema) + .build(); + } + + private McpToolResponse createMemoryRetrieveTool() { + Map inputSchema = new HashMap<>(); + inputSchema.put("type", "object"); + inputSchema.put("properties", Map.of( + "user_id", Map.of("type", "string", "description", "사용자 ID"), + "limit", Map.of("type", "integer", "description", "조회할 메모리 개수", "default", 10) + )); + inputSchema.put("required", Arrays.asList("user_id")); + + return McpToolResponse.builder() + .name("retrieve_memory") + .description("사용자의 메모리를 조회합니다") + .inputSchema(inputSchema) + .build(); + } + + private McpToolResponse createMemorySearchTool() { + Map inputSchema = new HashMap<>(); + inputSchema.put("type", "object"); + inputSchema.put("properties", Map.of( + "query", Map.of("type", "string", "description", "검색 쿼리"), + "user_id", Map.of("type", "string", "description", "사용자 ID (선택)"), + "limit", Map.of("type", "integer", "description", "검색 결과 개수", "default", 5) + )); + inputSchema.put("required", Arrays.asList("query")); + + return McpToolResponse.builder() + .name("search_memory") + .description("벡터 유사도 기반으로 메모리를 검색합니다") + .inputSchema(inputSchema) + .build(); + } + + // 프롬프트 생성 헬퍼 메서드들 + private McpPromptResponse createMemorySummaryPrompt() { + List arguments = new ArrayList<>(); + arguments.add(McpPromptResponse.PromptArgument.builder() + .name("memories") + .description("요약할 메모리 텍스트 목록") + .required(true) + .build()); + + return McpPromptResponse.builder() + .name("memory_summary") + .description("메모리 목록을 요약하는 프롬프트") + .arguments(arguments) + .build(); + } + + private McpPromptResponse createMemorySearchPrompt() { + List arguments = new ArrayList<>(); + arguments.add(McpPromptResponse.PromptArgument.builder() + .name("query") + .description("검색할 키워드") + .required(true) + .build()); + + return McpPromptResponse.builder() + .name("memory_search") + .description("메모리 검색을 위한 프롬프트") + .arguments(arguments) + .build(); + } +} diff --git a/src/main/java/com/pandol365/dewey/config/McpConfig.java b/src/main/java/com/pandol365/dewey/config/McpConfig.java new file mode 100644 index 0000000..1e53fbe --- /dev/null +++ b/src/main/java/com/pandol365/dewey/config/McpConfig.java @@ -0,0 +1,26 @@ +package com.pandol365.dewey.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * MCP 서버 관련 설정 + */ +@Configuration +public class McpConfig implements WebMvcConfigurer { + + /** + * CORS 설정 + * MCP 클라이언트가 다른 도메인에서 접근할 수 있도록 허용 + */ + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/mcp/**") + .allowedOrigins("*") + .allowedMethods("GET", "POST", "OPTIONS") + .allowedHeaders("*") + .maxAge(3600); + } +} + diff --git a/src/main/java/com/pandol365/dewey/domain/memory/model/Memory.java b/src/main/java/com/pandol365/dewey/domain/memory/model/Memory.java new file mode 100644 index 0000000..f93c3e9 --- /dev/null +++ b/src/main/java/com/pandol365/dewey/domain/memory/model/Memory.java @@ -0,0 +1,50 @@ +package com.pandol365.dewey.domain.memory.model; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 장기기억 엔티티 (Model) + * PostgreSQL에 영구 저장되는 정제된 메모리 데이터 + */ +@Entity +@Table(name = "memories") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Memory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String userId; + + @Column(nullable = false, columnDefinition = "TEXT") + private String memoryText; + + @Column(nullable = false) + @Builder.Default + private Integer importance = 1; // 1~5 + + @Column(columnDefinition = "JSONB") + private String tags; // JSON 형식의 태그 배열 + + @Column(name = "created_at", nullable = false, updatable = false) + @Builder.Default + private LocalDateTime createdAt = LocalDateTime.now(); + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + // 벡터 임베딩은 별도 테이블 또는 pgvector 컬럼으로 관리 + // Spring AI의 PgVectorStore를 사용할 경우 별도 처리 +} + diff --git a/src/main/java/com/pandol365/dewey/domain/memory/model/TemporaryMemory.java b/src/main/java/com/pandol365/dewey/domain/memory/model/TemporaryMemory.java new file mode 100644 index 0000000..8067725 --- /dev/null +++ b/src/main/java/com/pandol365/dewey/domain/memory/model/TemporaryMemory.java @@ -0,0 +1,33 @@ +package com.pandol365.dewey.domain.memory.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 임시 메모리 모델 (Model) + * Redis에 저장되는 임시 메모리 데이터를 표현 + * 엔티티가 아닌 도메인 모델 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TemporaryMemory { + + private String id; // Redis key + + private String userId; + + private String memoryText; + + private Integer importance; + + private LocalDateTime createdAt; + + private LocalDateTime expiresAt; // TTL 기반 만료 시간 +} + diff --git a/src/main/java/com/pandol365/dewey/domain/memory/repository/MemoryRepository.java b/src/main/java/com/pandol365/dewey/domain/memory/repository/MemoryRepository.java new file mode 100644 index 0000000..76c5dd2 --- /dev/null +++ b/src/main/java/com/pandol365/dewey/domain/memory/repository/MemoryRepository.java @@ -0,0 +1,35 @@ +package com.pandol365.dewey.domain.memory.repository; + +import com.pandol365.dewey.domain.memory.model.Memory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * Memory 엔티티에 대한 데이터 접근 계층 (Repository) + */ +@Repository +public interface MemoryRepository extends JpaRepository { + + /** + * 사용자 ID로 메모리 조회 + */ + List findByUserIdOrderByCreatedAtDesc(String userId); + + /** + * 사용자 ID와 중요도로 메모리 조회 + */ + List findByUserIdAndImportanceGreaterThanEqualOrderByCreatedAtDesc( + String userId, Integer minImportance); + + /** + * 사용자 ID로 최근 N개 메모리 조회 + */ + @Query("SELECT m FROM Memory m WHERE m.userId = :userId ORDER BY m.createdAt DESC") + List findRecentMemoriesByUserId(@Param("userId") String userId, + org.springframework.data.domain.Pageable pageable); +} + diff --git a/src/main/java/com/pandol365/dewey/domain/memory/service/MemoryService.java b/src/main/java/com/pandol365/dewey/domain/memory/service/MemoryService.java new file mode 100644 index 0000000..9042df5 --- /dev/null +++ b/src/main/java/com/pandol365/dewey/domain/memory/service/MemoryService.java @@ -0,0 +1,43 @@ +package com.pandol365.dewey.domain.memory.service; + +import com.pandol365.dewey.domain.memory.model.Memory; +import com.pandol365.dewey.domain.memory.model.TemporaryMemory; + +import java.util.List; + +/** + * 메모리 도메인 비즈니스 로직 인터페이스 (Service) + */ +public interface MemoryService { + + /** + * 임시 메모리를 Redis에 저장 + */ + TemporaryMemory storeTemporaryMemory(String userId, String memoryText, Integer importance); + + /** + * 사용자의 임시 메모리 조회 + */ + List getTemporaryMemories(String userId); + + /** + * 임시 메모리를 영구 메모리로 저장 (PostgreSQL) + */ + Memory savePermanentMemory(Memory memory); + + /** + * 사용자의 영구 메모리 조회 + */ + List getPermanentMemories(String userId, Integer limit); + + /** + * 벡터 유사도 기반 메모리 검색 + */ + List searchMemoriesByVector(String query, String userId, Integer limit); + + /** + * 키워드 기반 메모리 검색 + */ + List searchMemoriesByKeyword(String keyword, String userId, Integer limit); +} + diff --git a/src/main/java/com/pandol365/dewey/domain/memory/service/impl/MemoryServiceImpl.java b/src/main/java/com/pandol365/dewey/domain/memory/service/impl/MemoryServiceImpl.java new file mode 100644 index 0000000..0ff82fd --- /dev/null +++ b/src/main/java/com/pandol365/dewey/domain/memory/service/impl/MemoryServiceImpl.java @@ -0,0 +1,93 @@ +package com.pandol365.dewey.domain.memory.service.impl; + +import com.pandol365.dewey.domain.memory.model.Memory; +import com.pandol365.dewey.domain.memory.model.TemporaryMemory; +import com.pandol365.dewey.domain.memory.repository.MemoryRepository; +import com.pandol365.dewey.domain.memory.service.MemoryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +/** + * 메모리 도메인 비즈니스 로직 구현체 (Service) + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class MemoryServiceImpl implements MemoryService { + + private final MemoryRepository memoryRepository; + // TODO: Redis 연동 추가 필요 + // private final ReactiveRedisTemplate redisTemplate; + + @Override + public TemporaryMemory storeTemporaryMemory(String userId, String memoryText, Integer importance) { + log.info("임시 메모리 저장: userId={}, importance={}", userId, importance); + + // TODO: Redis에 저장하는 로직 구현 + TemporaryMemory temporaryMemory = TemporaryMemory.builder() + .id(UUID.randomUUID().toString()) + .userId(userId) + .memoryText(memoryText) + .importance(importance != null ? importance : 1) + .createdAt(LocalDateTime.now()) + .expiresAt(LocalDateTime.now().plusDays(3)) // TTL 3일 + .build(); + + // TODO: redisTemplate.opsForValue().set(key, temporaryMemory, Duration.ofDays(3)); + + return temporaryMemory; + } + + @Override + @Transactional(readOnly = true) + public List getTemporaryMemories(String userId) { + log.info("임시 메모리 조회: userId={}", userId); + + // TODO: Redis에서 조회하는 로직 구현 + return List.of(); + } + + @Override + public Memory savePermanentMemory(Memory memory) { + log.info("영구 메모리 저장: userId={}, importance={}", memory.getUserId(), memory.getImportance()); + + memory.setUpdatedAt(LocalDateTime.now()); + return memoryRepository.save(memory); + } + + @Override + @Transactional(readOnly = true) + public List getPermanentMemories(String userId, Integer limit) { + log.info("영구 메모리 조회: userId={}, limit={}", userId, limit); + + PageRequest pageRequest = PageRequest.of(0, limit != null ? limit : 10); + return memoryRepository.findRecentMemoriesByUserId(userId, pageRequest); + } + + @Override + @Transactional(readOnly = true) + public List searchMemoriesByVector(String query, String userId, Integer limit) { + log.info("벡터 기반 메모리 검색: query={}, userId={}, limit={}", query, userId, limit); + + // TODO: 벡터 유사도 검색 구현 (Spring AI PgVectorStore 사용) + return List.of(); + } + + @Override + @Transactional(readOnly = true) + public List searchMemoriesByKeyword(String keyword, String userId, Integer limit) { + log.info("키워드 기반 메모리 검색: keyword={}, userId={}, limit={}", keyword, userId, limit); + + // TODO: 키워드 검색 구현 (LIKE 또는 Full-Text Search) + return List.of(); + } +} + diff --git a/src/main/java/com/pandol365/dewey/exception/GlobalExceptionHandler.java b/src/main/java/com/pandol365/dewey/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..d032f0d --- /dev/null +++ b/src/main/java/com/pandol365/dewey/exception/GlobalExceptionHandler.java @@ -0,0 +1,85 @@ +package com.pandol365.dewey.exception; + +import com.pandol365.dewey.api.dto.response.McpJsonRpcResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +/** + * 글로벌 예외 처리 핸들러 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * 유효성 검증 예외 처리 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationExceptions( + MethodArgumentNotValidException ex) { + Map errors = new HashMap<>(); + ex.getBindingResult().getAllErrors().forEach((error) -> { + String fieldName = ((FieldError) error).getField(); + String errorMessage = error.getDefaultMessage(); + errors.put(fieldName, errorMessage); + }); + + Map response = new HashMap<>(); + response.put("errors", errors); + response.put("message", "Validation failed"); + + return ResponseEntity.badRequest().body(response); + } + + /** + * IllegalArgumentException 처리 + */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException( + IllegalArgumentException ex) { + log.error("Illegal argument exception: {}", ex.getMessage()); + + McpJsonRpcResponse.McpError error = McpJsonRpcResponse.McpError.builder() + .code(-32602) // Invalid params + .message(ex.getMessage()) + .data(null) + .build(); + + McpJsonRpcResponse response = McpJsonRpcResponse.builder() + .jsonrpc("2.0") + .error(error) + .build(); + + return ResponseEntity.badRequest().body(response); + } + + /** + * 일반 예외 처리 + */ + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception ex) { + log.error("Unexpected exception: ", ex); + + McpJsonRpcResponse.McpError error = McpJsonRpcResponse.McpError.builder() + .code(-32603) // Internal error + .message(ex.getMessage()) + .data(null) + .build(); + + McpJsonRpcResponse response = McpJsonRpcResponse.builder() + .jsonrpc("2.0") + .error(error) + .build(); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } +} + diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index c2ba219..1f931c3 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,17 @@ spring.application.name=dewey + +# PostgreSQL +spring.datasource.url=jdbc:postgresql://localhost:5432/dewey_memory +spring.datasource.username=dewey +spring.datasource.password=0bk1rWu98mGl5ea3 +spring.datasource.driver-class-name=org.postgresql.Driver + +# JPA +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.properties.hibernate.format_sql=true + +# Redis +spring.data.redis.host=localhost +spring.data.redis.port=6379