MCPサーバーのリファクタリング記録:コード分割とテスト実装
Model Context Protocol(MCP)に対応したRedmineサーバーの開発過程で実施したリファクタリングを解説します。特にモジュール分割による保守性の向上と、実環境での制約に基づくテスト戦略に焦点を当てて、実践的な開発知見を共有します。
MCPサーバーの開発では、基盤構築からコードの分割、そしてテスト実装を経て改善を重ねてきました。このプロジェクトの成果はGitHubで公開しています。本記事では、実際の開発プロセスとADRに基づく意思決定の過程を解説します。
1. プロジェクトの基盤構築
MCPサーバーの初期構築では、Node.js v18とTypeScriptを基盤に選択しました。ADR-0001に基づき、以下の基本設定を確立しました。
初期のディレクトリ構造
1.
2├── README.ja.md
3├── dist/ # コンパイル済みファイル用
4├── docs/
5│ └── adr/ # アーキテクチャ決定記録
6├── src/
7│ ├── index.ts # メインエントリーポイント
8│ ├── handlers.ts # MCPリクエストハンドラー
9│ ├── client.ts # Redmine APIクライアント
10│ ├── types.ts # 型定義
11│ └── config.ts # 設定
プロジェクトの基盤構築では、以下の主要な技術と設定を採用しました。Node.js v18以上を要求し、ES Modulesを使用することで、最新の機能と開発体験を確保しています。
パッケージの主要な依存関係
1{
2 "type": "module",
3 "engines": {
4 "node": ">=18"
5 },
6 "dependencies": {
7 "@modelcontextprotocol/sdk": "^1.1.0",
8 "zod": "^3.22.4"
9 },
10 "devDependencies": {
11 "typescript": "^5.5.4",
12 "tsx": "^4.7.0",
13 "jest": "^29.7.0"
14 }
15}
TypeScript設定と厳格な型チェック
TypeScriptの設定では、厳格な型チェックとES Modulesの特性を活かすため、以下のような設定を採用しました。
1{
2 "compilerOptions": {
3 "target": "es2018",
4 "module": "Node16",
5 "moduleResolution": "Node16",
6 "declaration": true,
7 "strict": true,
8 "isolatedModules": true,
9 "esModuleInterop": true,
10 "skipLibCheck": true
11 }
12}
この基本構造により、以下の利点を確保できました:
- シンプルで理解しやすい構造
- 関心の分離が適切に行われたファイル構成
- 他のMCPサーバー実装との一貫性
- 機能単位でのファイル分割による保守性の向上
- 現代的なTypeScript/Node.jsプロジェクトの慣行に従った設定
2. モジュールの分割:ディレクトリ構造の整理
ADR-0003で定義したモジュール分割の方針に従い、コードベースを整理しました。特にハンドラーの肥大化とテスタビリティの課題に対応するため、以下のような改善を実施しています。
開発が進むにつれ、handlers.tsが400行を超えて肥大化してきました。これは主にRedmine APIの豊富なエンドポイント群が原因です。REST API仕様のうち、Stableなエンドポイントだけでも以下が提供されています:
- Issues - チケットのCRUD、検索、ウォッチャー管理
- Projects - プロジェクトのCRUD、メンバー管理
- Users - ユーザー管理とロール制御
- Time Entries - 作業時間の記録と集計
それぞれのエンドポイントが複数のメソッドを持ち、カスタムフィールドやフィルタリング、ページネーションなどの共通機能もサポートする必要があります。この状況に対応するため、コードの分割と再構成を行いました。
1src/
2├── tools/ # リソースごとのツール定義
3│ ├── issues.ts # チケット関連
4│ ├── projects.ts # プロジェクト関連
5│ ├── time_entries.ts # 作業時間関連
6│ └── index.ts # エクスポート
7├── formatters/ # レスポンス整形
8│ ├── issues.ts
9│ ├── projects.ts
10│ ├── time_entries.ts
11│ └── index.ts
12├── lib/ # 共通ライブラリ
13│ ├── client.ts # Redmine APIクライアント
14│ ├── config.ts # 設定管理
15│ └── types.ts # 型定義
16├── handlers.ts # リクエストハンドラー
17└── index.ts # エントリーポイント
各モジュールの役割と分割方針は以下の通りです:
ツール定義(tools/):
- Redmineの各リソースタイプごとにファイルを分割
- リソースごとのCRUD操作の実装
- スキーマとバリデーションの定義
フォーマッター(formatters/):
- APIレスポンスのフォーマット処理
- MCPプロトコルに準拠した形式への変換
- エラーメッセージの標準化
ハンドラー(handlers.ts):
- ツールの実行制御とエラーハンドリング
- MCPプロトコルに従ったレスポンス生成
- ログ出力と監視
1handlers → tools → lib
2 ↘ ↗
3 formatters
この構造により、以下の機能を整理されたモジュールとして実装できました:
-
チケット関連機能(Issues API)
- GET /issues - 検索とフィルタリング
- GET /issues/:id - 詳細情報の取得
- POST /issues - 新規チケットの作成
- PUT /issues/:id - チケット情報の更新
- DELETE /issues/:id - チケットの削除
-
プロジェクト関連機能(Projects API)
- GET /projects - プロジェクト一覧
- GET /projects/:id - プロジェクト詳細
- カスタムフィールドのサポート
-
作業時間関連機能(Time Entries API)
- GET /time_entries - 作業時間の検索
- GET /time_entries/:id - 詳細情報の取得
- フィルタリングとページネーション
3. クライアントと型定義の分割
ADR-0006に基づき、APIクライアントと型定義の分割を実施しました。Node.jsのモジュール解決の制約を考慮しつつ、テストとメンテナンスがしやすい構造を目指しました。
APIクライアントの実装が進む中で、以下の課題が明らかになってきました:
- client.ts(約400行)に全APIクライアントロジックが集中
- types.tsが複数の型定義とスキーマで肥大化
- テストの作成と保守が困難
- コードの変更影響範囲が大きい
1src/
2├── lib/
3│ ├── client/ # APIクライアント関連
4│ │ ├── base.ts # 基本クライアント機能 + エラー
5│ │ ├── issues.ts # Issuesリソース用クライアント
6│ │ ├── projects.ts # Projectsリソース用クライアント
7│ │ ├── time_entries.ts # TimeEntriesリソース用クライアント
8│ │ └── index.ts # クライアントのエクスポート
9│ ├── types/ # 型定義関連
10│ │ ├── common.ts # 共通の型定義・定数
11│ │ ├── issues/ # Issues関連の型定義
12│ │ │ ├── schema.ts # Zodスキーマ
13│ │ │ └── types.ts # TypeScript型定義
14│ │ ├── projects/ # Projects関連の型定義
15│ │ └── time_entries/ # TimeEntries関連の型定義
分割にあたり、以下の方針を採用しました:
クライアント分割の方針:
- 既存のRedmineClientクラスの機能をリソースごとに分割
- base.tsに共通機能(performRequest, encodeQueryParams)とエラー定義を配置
- 各リソースのAPIメソッドを対応するファイルに移動
- インターフェースや機能は変更せず、ファイル分割のみ実施
型定義の分割方針:
- リソースごとに型定義とスキーマを分離
- 共通の型定義や定数はcommon.tsに集約
- 既存のバリデーション関数は関連するスキーマと同じファイルに配置
- 型定義の内容は変更せず、ファイル分割のみ実施
Node.jsのESM/CJS互換性に対応するため、以下の方針を採用しました:
- 全ての相対インポートで.js拡張子を使用(出力ファイルの拡張子に合わせる)
- 型定義のインポートは/index.jsを使用
- パッケージインポートは拡張子なしを維持
1src/
2└── lib/
3 └── __tests__/
4 ├── client/ # クライアントのテスト
5 │ ├── base.test.ts # 基本機能のテスト
6 │ ├── issues.test.ts
7 │ ├── projects.test.ts
8 │ └── time_entries.test.ts
9 └── types/ # 型定義のテスト
10 ├── common.test.ts
11 ├── issues/
12 ├── projects/
13 └── time_entries/
4. テストの実装:実環境での制約と対応
実データの入った環境で開発をしていた都合で、ADR-0004ではGETメソッドのみをテスト対象とする戦略を採用しました。実際のAPIレスポンスを使用したテスト実装により、実環境の動作を正確に検証できています。
実運用環境のRedmineインスタンスでの開発を考慮し、データの安全性を重視したテスト戦略を採用しました。GETメソッドに限定したテストとすることで、実データを活用しながらも安全性を確保しています。
1describe("GET /issues/:id", () => {
2 beforeEach(() => {
3 // 実際のAPIレスポンスをキャプチャしてテストに使用
4 const actualResponse = await client.get('/issues/1234');
5
6 // fetchのモックについては以下のドキュメントを参考にしました:
7 // https://jestjs.io/docs/mock-function-api
8 // https://medium.com/swlh/how-to-mock-a-fetch-api-request-with-jest-and-typescript-bb6adf673a00
9 jest.spyOn(global, 'fetch').mockResolvedValue({
10 ok: true,
11 status: 200,
12 json: async () => actualResponse
13 });
14 });
15
16 it('handles custom fields correctly', async () => {
17 const response = await issuesClient.getIssue(1234);
18
19 // カスタムフィールドの型と構造を検証
20 expect(response.custom_fields).toEqual(
21 expect.arrayContaining([
22 expect.objectContaining({
23 id: expect.any(Number),
24 name: expect.any(String),
25 value: expect.any(String)
26 })
27 ])
28 );
29 });
30});
31
32describe("POST/PUT/DELETE operations", () => {
33 it.skip("skipped for safety - see ADR-0004", () => {
34 // データ変更操作はスキップ
35 });
36});
実際のレスポンスを用いたテストの利点
開発初期の段階から実運用環境のRedmine APIレスポンスを使用してテストを実装したことで、以下のような具体的な利点が得られています。
- 実データのパターン網羅
開発環境のRedmineインスタンスには、実際のプロジェクト運用で発生する多様なデータパターンが存在していました。例えば、長文の説明文が含まれるチケット、複数の添付ファイルを持つチケット、カスタムフィールドが多用されているプロジェクトなど、モックデータでは想定が難しい多様なケースをテストできました。これにより、データ構造の把握が容易になり、型定義の精度も向上しました。
- エラーパターンの正確な把握
Redmine APIは、認証エラー(401)、アクセス権限エラー(403)、リソース未検出(404)など、様々なエラーを返します。これらのエラーレスポンスも実際のAPIから取得して検証することで、より正確なエラーハンドリングの実装が可能になりました。特に、Jest公式ドキュメントも参考にしながら、Redmineの特徴的なエラーレスポンス(カスタムフィールドのバリデーションエラーなど)についても、実際のレスポンスを基に適切な処理を実装できています。テスト実装に当たっては、Redmine REST API仕様とIssues API仕様を参考にしました。
1describe('GET /issues with pagination', () => {
2 it('handles large result sets correctly', async () => {
3 // 実際の大規模プロジェクトのデータを使用
4 const response = await issuesClient.listIssues({
5 project_id: 5678,
6 limit: 100,
7 offset: 200
8 });
9
10 // ページネーションメタデータの検証
11 expect(response.total_count).toBeGreaterThan(0);
12 expect(response.offset).toBe(200);
13 expect(response.limit).toBe(100);
14 expect(response.issues.length).toBeLessThanOrEqual(100);
15 });
16});
- パフォーマンス特性の把握
APIレスポンスの実測値を用いることで、以下のような実運用上の重要な知見が得られました:
- 大規模プロジェクトのチケット一覧取得に要する時間
- 添付ファイルを含むチケットのレスポンスサイズ
- カスタムフィールドが多数設定された場合のデータ構造の複雑さ
これらの知見は、MCPプロトコルの実装における課題の特定と改善につながっています。特に、大きなレスポンスデータの処理方法について検討を進めています。
- チケット間の関係性の検証
Redmineのチケットには、親子関係や関連チケットなど、複雑な関係性が存在します。実データを使用したテストでは、これらの関係性を含むチケットの取得と処理を正確に検証できました。特に、循環参照のような特殊なケースも発見され、適切なエラーハンドリングの実装につながりました。
- テストメンテナンスの効率化
実データを用いたテストは、API仕様の変更があった場合でも、テストケースの更新が容易です。新しいレスポンスをキャプチャして置き換えるだけで、テストを更新できます。これは、モックデータを手動でメンテナンスする場合と比べて、大きな効率化につながっています。MCPプロトコルの実装においても、この方針は効果的に機能しています。
まとめ:リファクタリングとテスト実装から得られた知見
モジュール分割とTypeScriptによる型安全性の確保、そして実環境の制約を考慮したテスト戦略の採用により、保守性の高いコードベースを実現できました。今後も段階的な改善を継続していく予定です。
取り急ぎ本記事にまとめましたが、MCPサーバーの開発過程において、最大の課題の一つはClaude Chatアプリとの安定的な接続です。大きなテキストデータを受け取った際にアプリケーションがクラッシュする問題が発生しており、現在も調査と対応を進めています。
MCPプロトコルとの形式の不一致も含めて、現在調査を進めており、この問題について引き続き開発を続けていきます。