こんにちは。最近、個人プロジェクトとしてRedmineのModel Context Protocol(MCP)サーバーを開発しています。今回は、開発過程で学んだTypeScriptでのモジュール分割とテストについて共有したいと思います。
開発の詳細な記録はこちらの記事にもまとめていますので、併せてご覧ください。
プロジェクトの背景
MCPは2024年にAnthropicが公開したオープンプロトコルで、AIアシスタントと外部データソースを接続するための標準インターフェースを提供します。私はRedmineとの連携に興味を持ち、個人的にMCPサーバーの実装に取り組んでいます。
ソースコードはGitHubで公開しています。
直面した課題
開発を進めていくと、以下のような問題に直面しました:
-
handlers.tsの肥大化
- 最初は「1ファイルで管理できる」と思っていた
- 気づいたら400行を超えていた
- RedmineのAPIエンドポイントが予想以上に多かった
-
クライアントコードの複雑化
- client.tsに全ての機能を詰め込んでいた
- RedmineとMCPの両方のインターフェースを扱う必要があった
- テストを書くのが困難に
リファクタリングの進め方
1. まずは基盤の整備から
Node.js v18とTypeScriptを採用し、以下のような構成でスタートしました:
// tsconfig.jsonの主要な設定
{
"compilerOptions": {
"target": "es2022",
"module": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
この設定は、MCP TypeScript SDKの構成を参考にしました。
2. コードの分割
最初は1つのファイルで管理していました。シンプルなプロジェクトなので1ファイルで十分だと考えていたのですが、開発が進むにつれてコードが400行を超え、管理が難しくなってきました。
そこで、以下のような構造に分割することにしました:
src/
├── tools/ # ツール定義
│ ├── issues.ts
│ ├── projects.ts
│ └── time_entries.ts
├── formatters/ # フォーマッター
│ ├── issues.ts
│ ├── projects.ts
│ └── time_entries.ts
├── lib/ # 共通ライブラリ
│ ├── client.ts # Redmine APIクライアント
│ ├── config.ts # 設定管理
│ └── types.ts # 型定義
└── handlers.ts # リクエストハンドラー
3. テストとの格闘
実際にデータの入っているRedmineサーバーで開発していたので、まずはモックを使ったテストを考えました:
jest.mock('../src/lib/client', () => ({
RedmineClient: jest.fn().mockImplementation(() => ({
getIssue: jest.fn().mockResolvedValue({
id: 1,
subject: 'Test Issue'
})
}))
}));
しかし、これはあまり意味のあるテストにならないと感じました。作ったモックが実際のRedmine APIの動作を正しく再現できているのか確信が持てなかったためです。
そこで方針を転換し、GETメソッドに限定した実データテストに切り替えました:
describe('IssuesClient', () => {
// テスト用の設定を読み込み
const config = loadTestConfig();
it('gets issue details correctly', async () => {
const client = new IssuesClient(config);
const issue = await client.getIssue(1234);
// スナップショットでレスポンス全体を検証
expect(issue).toMatchSnapshot();
// 重要なフィールドは個別に検証
expect(issue.id).toBeDefined();
expect(issue.subject).toBeDefined();
});
});
jest-mockで学んだこと
テスト実装で最も勉強になったのが、jest-mockの使い方です:
- partialモックが便利
// 一部のメソッドだけモック化
jest.spyOn(client, 'getIssue').mockResolvedValueOnce({
id: 1,
subject: 'Test'
});
- クラスのモック化のコツ
// 全メソッドをモック化するのではなく、必要なものだけを定義
jest.mock('../src/lib/client', () => {
return {
RedmineClient: jest.fn().mockImplementation(() => ({
getIssue: jest.fn(),
// 他のメソッドは未定義でOK
}))
};
});
- モックのリセット管理
describe('IssuesClient', () => {
beforeEach(() => {
jest.resetAllMocks();
});
afterAll(() => {
jest.restoreAllMocks();
});
});
- 型定義の活用
// モックの戻り値の型を明示的に指定
const mockGetIssue = jest.fn() as jest.MockedFunction<typeof client.getIssue>;
mockGetIssue.mockResolvedValueOnce({
id: 1,
subject: 'Test'
} as Issue); // 型をキャストして型安全性を確保
現在の課題
まだいくつかの課題に取り組んでいます:
-
Claude Chatアプリとの接続
- 大きなテキストデータでクラッシュする
- メッセージサイズの最適化が必要
-
パフォーマンスの改善
- 大規模プロジェクトでの応答が遅い
- カスタムフィールドの処理が重い
学んだこと
このプロジェクトを通じて、以下のことを学びました:
-
早めの分割が大事
- 「後で分ければいいや」は危険
- コードの見通しが悪くなる前に分割を検討
-
実データでのテストの重要性
- モックだけでは実際の動作を正しく検証できない
- 特にGETメソッドでの実データテストは価値がある
-
jest-mockの使い所
- 全てをモック化する必要はない
- 部分的なモック化で十分なケースが多い
まとめ
個人プロジェクトとして取り組んだMCPサーバーの開発でしたが、実践的な学びが多くありました。特にTypeScriptでのモジュール分割とテストについて、貴重な経験を得ることができました。
まだまだ改善の余地はありますが、この経験が皆さんのプロジェクトでも参考になれば幸いです。