MCPサーバーをローカルで動かすのは簡単だが、本番環境に持ち込もうとすると壁にぶつかる。

MicrosoftのIndustry Solutions Engineering(ISE)チームとNovo NordiskのClinical AIチームは、製薬企業向けの本番MCPサーバーを共同開発し、その設計ノウハウを公開した。GitHub Copilotと連携し、Azureコンテナアプリ上で動作するこのサーバーには、本番運用で必要になる設計パターンが凝縮されている。

この記事でわかること:

  • ツールをコードを変えずに追加・削除する「動的検出」の仕組み
  • フィーチャーフラグでツールのON/OFFを制御する方法
  • LangfuseとApplication Insightsで2層の可観測性を実現する設計
  • ローカル開発と本番Azureで設定だけ切り替えるステートレス設計
  • Semantic KernelでMCPツールをサーバー側からオーケストレーションする方法

https://devblogs.microsoft.com/ise/observable_discoverable_mcp_server/

プロジェクトの背景

このMCPサーバーは、臨床試験データの変換を自動化するために作られた。SDTM(Study Data Tabulation Model)からADaM(Analysis Data Model)への変換作業は、SASやRといった統計プログラミング言語を使う専門工程で、手作業では時間がかかる。

MicrosoftのISEチームが目指したのは、AIが過去コードからパターンを学習し、新しい分析プログラムを開発者が素早く作れるようにすることだった。結果として、22の要件を持つ本番MCPサーバーが生まれた。その中から汎用性の高い5つの設計パターンを紹介する。

1. 起動時にツールを自動検出する仕組み

もっとも重要な要件が「ランタイムでのツール検出」だった。当初の実装では、ツールを追加するたびにサーバーの設定ファイルに数十行を追加する必要があり、コードが肥大化した。

解決策は、ツール実装をサーバーコードから分離することだ。ToolBaseという抽象基底クラスを導入し、すべてのツールはこのクラスを継承する。サーバーは起動時に指定ディレクトリを走査し、ToolBaseを実装したクラスを自動で発見する。

フォルダ構造はシンプルだ。

src/
  app/   # MCPサーバー本体
  tools/ # ツール置き場(サーバーコードとは別)
    Tool_A/
      Version_1.0/
      Version_1.1/
    Tool_B/
      Version_2.0/

新しいツールを追加したければ、tools/以下にフォルダとクラスを作り、サーバーを再起動するだけでいい。サーバー本体のコードには一切触れない。

2. フィーチャーフラグでツールを個別ON/OFF

各ツールにはis_enabled()メソッドが必要で、サーバーはここを呼んで有効・無効を判断する。有効なツールだけをMCPプロトコルに登録する仕組みだ。

フラグのソースは3層ある。環境変数(.envファイル)、Azure App Configuration Service、Azure Key Vaultを順番に確認し、最初に見つかった設定を使う。この優先順位のおかげで、Azureで全体設定をしながら、開発者が手元の.envで特定バージョンだけ上書きできる。

@staticmethod
async def is_enabled() -> bool:
    return Configuration.is_enabled(
        "FILE_ENDPOINTS",
        f"enable_{upload_file_tool.versioned_name}",
        default=True
    )

新バージョンのツールを段階的にロールアウトする場合も、コードデプロイなしにフラグ操作だけで切り替えられる。

3. 2層の可観測性:データサイエンスとソフトウェアエンジニアリング

可観測性の要件をあえて2種類に分けた点が面白い。

データサイエンス用の観測にはLangfuseを使う。トークン使用量、コストパーコール、ツール呼び出しの入出力を自動で記録する。ToolBaserun()メソッドに@observeデコレータを1行追加するだけで、すべての派生ツールに観測機能が自動適用される。

@observe()
async def run(self, arguments: Dict[str, Any], ctx: Context) -> Any:
    lf = get_langfuse_client()
    lf.update_current_span(name=self.name)
    ...

ソフトウェアエンジニアリング用の観測にはAzure Application InsightsとOpenTelemetryを組み合わせる。スパン対応のプロバイダーを設定することで、複数のツール呼び出しにまたがるトレースが相関付けられる。Pythonの標準ロギング(logger.info()など)もApplication Insightsに自動転送される。

この2層構造により、「このツールは何トークン消費したか」(DS観点)と「このリクエストはどのパスで処理されたか」(SE観点)を独立して追跡できる。

4. ストレージファサードでローカル/クラウドを切り替える

MCPツールをステートレスに保つために、StorageFacadeVectorFacadeの2つのファサードクラスを導入した。いずれもプロバイダーパターンを内部で使い、設定値に基づいてローカルファイルシステムかAzure Storageかを透過的に切り替える。

def __init__(self, storage_type: Optional[str] = None):
    storage_type = Configuration().get_value("STORAGE_TYPE", "azure")

    if storage_type == FileStorageProvider.name:
        self.storage = FileStorageProvider()
    elif storage_type == AzureStorageProvider.name:
        self.storage = AzureStorageProvider()

開発者はローカルでSTORAGE_TYPE=file、本番ではSTORAGE_TYPE=azureに設定するだけでよく、ツールのコードは変えない。ツール間で大量データをやり取りする際も、テキストを直接渡すのではなく「ストレージデスクリプタ」を渡すことでパフォーマンスが改善された。

5. Semantic Kernelによるサーバー側オーケストレーション

複雑なワークフローを実現するアプローチとして、クライアント側とサーバー側の2つを試した。

クライアント側(GitHub Copilotに詳細な指示ファイルを渡す方式)は開発中は機動的だが、数百人のユーザーに最新の指示ファイルを配布・更新するのが運用上の課題になった。

そこでSemantic Kernelを使ったサーバー側オーケストレーションに切り替えた。サーバーがツールの呼び出し順序と引数受け渡しを管理し、クライアントはシンプルな高レベルツールだけを見る形だ。MCPサーバーを更新すれば全ユーザーが即座に変更を受け取り、クライアント側の作業は不要になる。

ツールが別プロセスやリモートサーバーにある場合はRemoteToolクラスで抽象化する。MCP elicitation(ツールがクライアントに追加情報を問い合わせる機能)やプログレス通知もこのクラスが透過的に処理する。

開発時とクラウドで変わるのは設定だけ

5つのパターンをまとめると、このアーキテクチャの本質は「コードを変えずに環境を切り替える」という設計思想だ。

  • ツールの追加はフォルダ追加だけ。サーバーコードは変えない
  • ツールのON/OFFはフラグ操作。デプロイは不要
  • ストレージ先はenv設定だけで切り替え。コードは共通
  • オーケストレーション変更はサーバー更新だけ。クライアント不要

MCPサーバーを個人ツールから組織の共有基盤へ昇格させるときに、この設計パターンは参考になる。