Spring MCPパターンとOpenAIの統合実装

# Spring MCPパターンとOpenAIの統合実装

## はじめに

Spring MCP（Model-Controller-Provider）パターンは、アプリケーションの責務を明確に分離し、保守性とテスト容易性を向上させるアーキテクチャパターンです。この記事では、Spring MCPパターンを使用してOpenAIのAPIと統合する方法を説明します。

## 1. MCPパターンの概要

### 各レイヤーの役割

- **Model**: データの構造とビジネスロジックを定義
- **Controller**: リクエストの受付とレスポンスの返却を担当
- **Provider**: 外部サービスとの連携を担当

## 2. プロジェクトのセットアップ

### 依存関係の追加

```groovy
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter'
    implementation 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}
```

### アプリケーションの設定

```properties
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.model=gpt-3.5-turbo
```

## 3. MCPパターンの実装

### Model層の実装

```java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ChatMessage {
    private String role;
    private String content;
    private LocalDateTime timestamp;
}

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ChatRequest {
    private String message;
    private String sessionId;
}

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ChatResponse {
    private String response;
    private String sessionId;
    private LocalDateTime timestamp;
}
```

### Provider層の実装

```java
@Service
@Slf4j
public class OpenAIChatProvider {
    private final ChatClient chatClient;
    private final Map<String, List<ChatMessage>> conversationHistory;

    public OpenAIChatProvider(ChatClient.Builder builder) {
        this.chatClient = builder.build();
        this.conversationHistory = new ConcurrentHashMap<>();
    }

    public ChatResponse generateResponse(ChatRequest request) {
        try {
            // 会話履歴の取得
            List<ChatMessage> history = conversationHistory
                .computeIfAbsent(request.getSessionId(), k -> new ArrayList<>());

            // プロンプトの構築
            String prompt = buildPrompt(request.getMessage(), history);

            // OpenAI APIの呼び出し
            String response = chatClient.prompt(prompt)
                .call()
                .content();

            // 会話履歴の更新
            updateHistory(request.getSessionId(), request.getMessage(), response);

            return new ChatResponse(
                response,
                request.getSessionId(),
                LocalDateTime.now()
            );
        } catch (Exception e) {
            log.error("OpenAI API呼び出し中にエラーが発生しました", e);
            throw new ChatProviderException("チャット応答の生成に失敗しました", e);
        }
    }

    private String buildPrompt(String message, List<ChatMessage> history) {
        StringBuilder prompt = new StringBuilder();
        
        // 会話履歴の追加
        for (ChatMessage msg : history) {
            prompt.append(msg.getRole())
                  .append(": ")
                  .append(msg.getContent())
                  .append("\n");
        }
        
        // 現在のメッセージの追加
        prompt.append("User: ").append(message);
        
        return prompt.toString();
    }

    private void updateHistory(String sessionId, String userMessage, String aiResponse) {
        List<ChatMessage> history = conversationHistory.get(sessionId);
        
        history.add(new ChatMessage("user", userMessage, LocalDateTime.now()));
        history.add(new ChatMessage("assistant", aiResponse, LocalDateTime.now()));
        
        // 履歴のサイズ制限（最新の10件を保持）
        if (history.size() > 10) {
            history.subList(0, history.size() - 10).clear();
        }
    }
}
```

### Controller層の実装

```java
@RestController
@RequestMapping("/api/chat")
@Slf4j
public class ChatController {
    private final OpenAIChatProvider chatProvider;

    public ChatController(OpenAIChatProvider chatProvider) {
        this.chatProvider = chatProvider;
    }

    @PostMapping
    public ResponseEntity<ChatResponse> chat(@RequestBody ChatRequest request) {
        log.info("チャットリクエスト受信: sessionId={}, message={}", 
            request.getSessionId(), request.getMessage());
        
        try {
            ChatResponse response = chatProvider.generateResponse(request);
            return ResponseEntity.ok(response);
        } catch (ChatProviderException e) {
            log.error("チャット処理中にエラーが発生しました", e);
            return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(new ChatResponse(
                    "申し訳ありません。処理中にエラーが発生しました。",
                    request.getSessionId(),
                    LocalDateTime.now()
                ));
        }
    }
}
```

## 4. 例外処理の実装

```java
public class ChatProviderException extends RuntimeException {
    public ChatProviderException(String message, Throwable cause) {
        super(message, cause);
    }
}

@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    @ExceptionHandler(ChatProviderException.class)
    public ResponseEntity<ErrorResponse> handleChatProviderException(ChatProviderException e) {
        log.error("チャットプロバイダーでエラーが発生しました", e);
        return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse("チャット処理に失敗しました", e.getMessage()));
    }
}
```

## 5. 使用例

### cURLを使用したAPI呼び出し

```bash
curl -X POST http://localhost:8080/api/chat \
  -H "Content-Type: application/json" \
  -d '{
    "message": "こんにちは、自己紹介をしてください",
    "sessionId": "user123"
  }'
```

## 6. テストの実装

```java
@SpringBootTest
class OpenAIChatProviderTest {
    @Autowired
    private OpenAIChatProvider chatProvider;

    @Test
    void testGenerateResponse() {
        // テストデータの準備
        ChatRequest request = new ChatRequest(
            "こんにちは",
            "test-session-1"
        );

        // テストの実行
        ChatResponse response = chatProvider.generateResponse(request);

        // アサーション
        assertNotNull(response);
        assertNotNull(response.getResponse());
        assertEquals("test-session-1", response.getSessionId());
    }
}
```

## 7. ベストプラクティス

1. **セッション管理**
   - セッションIDの適切な生成と管理
   - セッションの有効期限の設定

2. **エラーハンドリング**
   - 適切な例外階層の設計
   - エラーメッセージの国際化

3. **パフォーマンス最適化**
   - 会話履歴のサイズ制限
   - キャッシュの活用

4. **セキュリティ**
   - 入力値のバリデーション
   - レート制限の実装

## まとめ

この記事では、Spring MCPパターンを使用してOpenAIのAPIと統合する方法を説明しました。MCPパターンを採用することで、以下のメリットが得られます：

- 責務の明確な分離
- テスト容易性の向上
- コードの保守性向上
- 拡張性の確保

## 参考資料

- [Spring AI公式ドキュメント](https://spring.io/projects/spring-ai)
- [OpenAI APIドキュメント](https://platform.openai.com/docs/api-reference)
- [Spring Boot公式ドキュメント](https://spring.io/projects/spring-boot)