3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

開発の詳細な記録はこちらの記事にもまとめていますので、併せてご覧ください。

プロジェクトの背景

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

ソースコードはGitHubで公開しています。

直面した課題

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

  1. handlers.tsの肥大化

    • 最初は「1ファイルで管理できる」と思っていた
    • 気づいたら400行を超えていた
    • RedmineのAPIエンドポイントが予想以上に多かった
  2. クライアントコードの複雑化

    • 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の使い方です:

  1. partialモックが便利
// 一部のメソッドだけモック化
jest.spyOn(client, 'getIssue').mockResolvedValueOnce({
  id: 1,
  subject: 'Test'
});
  1. クラスのモック化のコツ
// 全メソッドをモック化するのではなく、必要なものだけを定義
jest.mock('../src/lib/client', () => {
  return {
    RedmineClient: jest.fn().mockImplementation(() => ({
      getIssue: jest.fn(),
      // 他のメソッドは未定義でOK
    }))
  };
});
  1. モックのリセット管理
describe('IssuesClient', () => {
  beforeEach(() => {
    jest.resetAllMocks();
  });
  
  afterAll(() => {
    jest.restoreAllMocks();
  });
});
  1. 型定義の活用
// モックの戻り値の型を明示的に指定
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でのモジュール分割とテストについて、貴重な経験を得ることができました。

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

参考文献

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?