RedmineのMCPサーバー実装で学んだTypeScriptでのモジュール分割とテスト

こんにちは。最近、個人プロジェクトとしてRedmineのModel Context Protocol（MCP）サーバーを開発しています。今回は、開発過程で学んだTypeScriptでのモジュール分割とテストについて共有したいと思います。

開発の詳細な記録は[こちらの記事](https://tech.jugoya.ai/blog/2025-01-07-redmine-mcp-server-development)にもまとめていますので、併せてご覧ください。

## プロジェクトの背景

MCPは2024年にAnthropicが公開したオープンプロトコルで、AIアシスタントと外部データソースを接続するための標準インターフェースを提供します。私はRedmineとの連携に興味を持ち、個人的にMCPサーバーの実装に取り組んでいます。

ソースコードは[GitHub](https://github.com/yonaka15/mcp-server-redmine)で公開しています。

## 直面した課題

開発を進めていくと、以下のような問題に直面しました：

1. handlers.tsの肥大化
   - 最初は「1ファイルで管理できる」と思っていた
   - 気づいたら400行を超えていた
   - RedmineのAPIエンドポイントが予想以上に多かった

2. クライアントコードの複雑化
   - client.tsに全ての機能を詰め込んでいた
   - RedmineとMCPの両方のインターフェースを扱う必要があった
   - テストを書くのが困難に

## リファクタリングの進め方

### 1. まずは基盤の整備から

Node.js v18とTypeScriptを採用し、以下のような構成でスタートしました：

```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サーバーで開発していたので、まずはモックを使ったテストを考えました：

```typescript
jest.mock('../src/lib/client', () => ({
  RedmineClient: jest.fn().mockImplementation(() => ({
    getIssue: jest.fn().mockResolvedValue({
      id: 1,
      subject: 'Test Issue'
    })
  }))
}));
```

しかし、これはあまり意味のあるテストにならないと感じました。作ったモックが実際のRedmine APIの動作を正しく再現できているのか確信が持てなかったためです。

そこで方針を転換し、**GETメソッドに限定した実データテスト**に切り替えました：

```typescript
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の使い方です：

1. **partialモックが便利**
```typescript
// 一部のメソッドだけモック化
jest.spyOn(client, 'getIssue').mockResolvedValueOnce({
  id: 1,
  subject: 'Test'
});
```

2. **クラスのモック化のコツ**
```typescript
// 全メソッドをモック化するのではなく、必要なものだけを定義
jest.mock('../src/lib/client', () => {
  return {
    RedmineClient: jest.fn().mockImplementation(() => ({
      getIssue: jest.fn(),
      // 他のメソッドは未定義でOK
    }))
  };
});
```

3. **モックのリセット管理**
```typescript
describe('IssuesClient', () => {
  beforeEach(() => {
    jest.resetAllMocks();
  });
  
  afterAll(() => {
    jest.restoreAllMocks();
  });
});
```

4. **型定義の活用**
```typescript
// モックの戻り値の型を明示的に指定
const mockGetIssue = jest.fn() as jest.MockedFunction<typeof client.getIssue>;
mockGetIssue.mockResolvedValueOnce({
  id: 1,
  subject: 'Test'
} as Issue);  // 型をキャストして型安全性を確保
```

## 現在の課題

まだいくつかの課題に取り組んでいます：

1. Claude Chatアプリとの接続
   - 大きなテキストデータでクラッシュする
   - メッセージサイズの最適化が必要

2. パフォーマンスの改善
   - 大規模プロジェクトでの応答が遅い
   - カスタムフィールドの処理が重い

## 学んだこと

このプロジェクトを通じて、以下のことを学びました：

1. **早めの分割が大事**
   - 「後で分ければいいや」は危険
   - コードの見通しが悪くなる前に分割を検討

2. **実データでのテストの重要性**
   - モックだけでは実際の動作を正しく検証できない
   - 特にGETメソッドでの実データテストは価値がある

3. **jest-mockの使い所**
   - 全てをモック化する必要はない
   - 部分的なモック化で十分なケースが多い

## まとめ

個人プロジェクトとして取り組んだMCPサーバーの開発でしたが、実践的な学びが多くありました。特にTypeScriptでのモジュール分割とテストについて、貴重な経験を得ることができました。

まだまだ改善の余地はありますが、この経験が皆さんのプロジェクトでも参考になれば幸いです。

## 参考文献

- [Model Context Protocol](https://github.com/modelcontextprotocol)
- [Jest公式ドキュメント](https://jestjs.io/docs/getting-started)
- [Redmine REST API仕様](https://www.redmine.org/projects/redmine/wiki/Rest_api)