スポンサーリンク

こうして動いた。AWS Lambda上のMCPサーバー構築 – エラーと戦った日々の全記録

この記事は個人的な日記に近いです。

AWS Lambda 上で MCP サーバーを構築する時の試行錯誤を LLM にドラマチックな感じにして欲しいとお願いしたらこのようになりました。

もし 「AWS Lambda で MCP サーバーを構築方法を知りたいだけだ」って人は下記の記事にシンプルにまとめているのでそちらの記事を見ていただく方がいいかもしれません。

MCP サーバーをリモート化したくなった背景

ローカル PC 上で Cursor やその他の AI ツールを動かしていると、その裏側で活躍する MCP(Model Context Protocol)サーバーの存在に気づきます。

非常に便利ですが、サーバーの起動に uvx が必要だったり、Docker が必要だったり、チームでの共有が難しかったりと、悩みの種も尽きません。

自分は社内向けの MCP ツールの開発を担当しているのですが、その MCP ツールをまとめる MCP サーバーはローカルで起動していました。

ある日、他のメンバーが MCP サーバーのセットアップをしたいとのことで、手伝いをしていたら uvx の仕様の問題で Windows 環境では MCP サーバーの起動が非常に難しいことがわかりました。

エンジニア相手なら WSL 使ってと言えますが、相手はエンジニアではないのでそのようなお願いもできません。

悩んでいたときにふと思いました。

「そうだ、このサーバーを AWS Lambda に乗せて、サーバーレス化しよう!」

そう考えたのが、この長い旅の始まりでした。

スケーラブルで、低コストで、メンテナンスも楽。そんな夢のアーキテクチャは、簡単なはずでした。

しかし、現実は数々の不可解なエラーとの戦いの連続だったのです。

この記事は、単なる成功手順書ではありません。私が直面した数々のエラー、間違った仮説、そして「灯台下暗し」だった発見の瞬間まで、そのすべてを記録したトラブルシューティングの物語です。

第1章:最初の壁 – Mangumとライフサイクル管理の罠

アーキテクチャの心臓部である Lambda と Web アプリケーションをどう繋ぐか。

Python 製の Web アプリケーションを Lambda で動かすには、Lambda のイベント形式と、Web フレームワークが話すASGI/WSGI という「言語」を繋ぐ「アダプター」が必要です。有名な選択肢は2つあります。

  1. Mangum: FastAPI/Flask など、特定のフレームワークに特化した「専門通訳」。
  2. aws-lambda-adapter: あらゆる Web サーバーと連携できる「汎用プロキシ」。

私は最初、Mangumというアダプターを選択しました。これは FastAPI のような特定の Python フレームワークに特化しており、軽量で高性能な「専門通訳」として知られています。FastMCP が FastAPI ベースである以上、これは最も自然な選択に見えました。

しかし、この選択が最初の大きな壁となります。sam local start-api で実行すると、アプリケーションは起動せず、LifespanFailure という見慣れないエラーを吐き出したのです。

[ERROR] LifespanFailure: Lifespan startup failure. Traceback (mostrecentcalllast):
...
RuntimeError:StreamableHTTPSessionManager.run()canonlybecalledonceperinstance.Createanewinstanceifyouneedtorunagain.

「ライフスパンの失敗?」

ライフスパンとは、FastAPI のような ASGI アプリケーションが、サーバーの起動時と終了時に一度だけ実行する初期化・後処理の仕組みです。エラーメッセージを読み解くと、どうやら Mangum が管理しようとするライフスパンと、FastMCP が内部で持っている独自のライフスパン管理が衝突しているようでした。

安易な解決策として、Mangum にライフスパンを無視させる lifespan="off" というオプションを試してみました。すると、今度は別のエラーが発生します。

RuntimeError:Taskgroupisnotinitialized.Makesuretouserun().

これは、FastMCP が必要とする重要な初期化処理が、lifespan="off" によってスキップされてしまったことを意味していました。まさに八方塞がりです。

ここから得られた最初の教訓は、「ライブラリが持つ独自のライフサイクル管理と、Lambda のアダプターが提供するライフサイクル管理は、時に激しく衝突する」ということでした。

この根深い相性問題を Mangum 側で解決するのは困難だと判断した私は、アプローチの変更を決断します。アプリケーションとはより疎結合に、独立したプロキシとして動作する aws-lambda-adapter への乗り換えです。

「これなら、アプリケーションの複雑なライフサイクルに干渉することなく、純粋な HTTP サーバーとして連携できるはずだ。」

新たな希望を胸に、私は aws-lambda-adapter を組み込んだ新しい Dockerfile を書き始めました。この時点では、まさかこの先に、さらに深く、不可解なエラーの深淵が待ち構えているとは知る由もなかったのです。

第2章:起動は成功、しかし通信はクラッシュ – 502 Bad Gateway と低レイヤーの謎

Mangum とのライフサイクル問題を回避すべく、私は aws-lambda-adapter への乗り換えを決断しました。このアプローチは、アプリケーションと Lambda の実行環境を疎結合にし、より安定した動作が期待できます。

そして、sam local start-api を実行すると…ついに、その瞬間は訪れました。

INFO:    Startedserverprocess [12]
INFO:    Waitingforapplicationstartup.
INFO:    Applicationstartupcomplete.
INFO:    Uvicornrunningon [http://0.0.0.0:8080](http://0.0.0.0:8080) (Press CTRL+C to quit)

Uvicornが起動した!

Lambda の初期化(INIT)が成功し、コンテナ内で Web サーバーが正常にリクエストを待ち受けている。長い戦いの末に見たこのログは、まさに希望の光でした。

しかし、その喜びは長くは続きませんでした。curl で最初のリクエストを送った瞬間、ターミナルは再び不穏なログで埋め尽くされたのです。

thread'main'panickedat...hyper::Error(IncompleteMessage)
...
2025-06-2415:11:11127.0.0.1-- [24/Jun/202515:11:11]"GET / HTTP/1.1"502-

起動はする。しかし、最初の通信で内部コンポーネントがクラッシュし、クライアントには 502 Bad Gateway が返ってくる。一体、何が起きているのか?

この謎を解く鍵は、アプリケーションが返す「応答」そのものにありました。低レイヤーのエラーは、アプリケーションが何か「不正な応答」をした結果、プロキシ役の Adapter がパニックを起こしているのではないか、と私は仮説を立てました。

そこで、私は一度、FastMCP の複雑な仕様から離れ、HTTP プロトコルの基本に立ち返って、サーバーとの対話を一つずつデバッグしていくことにしました。

発見1:玄関がない家 (404 Not Found)

まずは最も単純な GET /リクエストを送ってみます。すると、Uvicorn のログに興味深い記録が残っていました。

INFO:127.0.0.1:41484-"GET / HTTP/1.1"404NotFound

サーバーは沈黙していたのではなく、「そんなページ(パス)は見つかりません」と、きちんと応答していたのです。FastMCP は /v1/chat/completions のような特定のパスしか待ち受けておらず、家の玄関にあたるルートパス / が定義されていませんでした。

解決策: サーバーに「玄関」としてヘルスチェック用のエンドポイントを追加しました。

# app.py
# ...

# 親アプリにヘルスチェック用の玄関を追加します
@app.get("/")
asyncdefhealth_check():
   return{"status":"ok","message":"MCP server is ready."}

発見2:ドレスコード違反 (406 Not Acceptable)

次に、MCP の仕様に沿って POST リクエストを送ると、今度は 406 Not Acceptable というエラーが返ってきました。しかし、そのエラーメッセージは非常に親切でした。

"message":"Not Acceptable: Client must accept both application/json and text/event-stream"

これは、サーバーからの「ドレスコード」の指定です。「私と話したいなら、Accept ヘッダーで、JSON とストリーミングの両方に対応できる服装でお越しください」という意味でした。

解決策: curl コマンドに、要求された通りの Accept ヘッダーを追加しました。

curl-XPOST <URL>\
-H"Content-Type: application/json"\
-H"Accept: application/json, text/event-stream"\
-d'{...}'

これらの地道な HTTP レベルの修正を適用した結果、ついにサーバーは 200 OK を返し、それに伴ってあれほど悩まされた低レイヤーのパニックエラーも嘘のように消え去りました。

このことから得られた教訓は、「アプリケーションに正しく応答させることこそが、実行環境全体を安定させる鍵である」ということでした。

ローカルでの基本的な動作は確認できた。いよいよ、このコンテナを本物の AWS Lambda 環境にデプロイする時が来ました。

私は sam deploy --guided コマンドを実行し、CloudFormation のスタックが「CREATE_COMPLETE」の緑色の文字に変わるのを固唾をのんで見守りました。

数分後、ターミナルに出力された真新しい Lambda Function URL。これこそが、クラウド上で稼働する私の新しい MCP サーバーです。

私は期待に胸を膨らませ、この URL を Cursor に設定し、最初の接続を試みました。しかし、そこで私を待ち受けていたのは、ローカル環境では決して見ることのできなかった、サーバーレスアーキテクチャ特有の、最も根深い問題だったのです。

ようやくサーバーと正常な会話が成立し、私は安堵しました。しかし、それは本当の戦いの始まりに過ぎなかったのです…。

第3章:最大の敵 – 「ステートレス」という名の壁

ついに AWS 上にデプロイされた MCP サーバー。

Cursor の MCP の設定に新しい Function URL を設定すると、ツール一覧の取得には成功しました!

長い戦いの末、クライアントとサーバーがクラウド上で初めて正常に通信できた瞬間でした。

しかし、その喜びも束の間。実際にツールを使おうとすると、No valid session ID provided という無情なエラーメッセージが、本番環境の洗礼として私に襲いかかったのです。

[error] user-my-mcp-server: Error POSTing to endpoint (HTTP400): Bad Request: No valid session ID provided

ツール一覧は取得できたのに、なぜ次の「ツール実行」リクエストでセッションが消えてしまうのか?

この謎の答えは、AWS Lambda、ひいては現代のサーバーレスアーキテクチャの根幹をなす設計思想、「ステートレス(Stateless)」にありました。

Lambda とインメモリセッションの致命的な相性問題

この問題を理解するために、スーパーのレジの例え話をさせてください。

  • クライアント (Cursor): あなた(お客さん)
  • Lambda コンテナ: 1番レジ、2番レジ、3番レジ…
  • インメモリセッション: レジ係の短期的な記憶
  1. あなたは「ツール一覧の取得」という買い物のために、空いていた1番レジに並びます。1番レジの係員は「このお客さんは今、買い物を始めたな」と短期的に記憶します。(ここでセッションがコンテナ1のメモリに作られます)。
  2. あなたはすぐに、「ツールの実行」という次の買い物のために、隣で空いていた2番レジに並びます。
  3. あなたが2番レジの係員に「さっきの買い物の続きです」と言っても、2番レジの係員はあなたのことを知りません。その情報は1番レジの係員の記憶の中にしかないからです。これが「セッションIDがない」という状況の正体です。

AWS Lambda は、たくさんのリクエストを効率的にさばくため、必要に応じて複数のコンテナ(レジ係)を同時に、並列で起動します。 連続したリクエストであっても、それぞれが別のコンテナに割り当てられる可能性があるのです。

そのため、コンテナのメモリ上で状態(セッション)を管理する FastMCP のデフォルトの設計は、Lambda の実行モデルとは根本的に適合しなかったのです。

ここから得られた最大の教訓は、「サーバーレスアプリケーションは、ステートレスを前提に設計しなければならない。状態は必ず DynamoDB のような外部ストアに保存する」という、サーバーレス開発の鉄則でした。

エピローグ:そして、サーバーは動き出した

「FastMCP は Lambda に不向きなのか…?セッション管理を DynamoDB などの外部ストアで自前で実装し直さなければならないのか…?」

そう途方に暮れかけた時、私はもう一度 FastMCP の公式ドキュメントに立ち返りました。そして、これまで見落としていた一つのオプションを発見します。

stateless_http: Whether to use stateless mode (new transport per request)

http.create_streamable_http_app 関数のこの引数こそが、ライブラリ開発者が Lambda のようなステートレス環境のために用意してくれていた、「正解」でした。

このオプションを有効にすると、FastMCP はインメモリでセッションを管理するのをやめ、リクエスト間で必要な情報をやり取りするステートレスなモードに切り替わります。

私は急いで app.py を修正しました。

# app.py
# ...
mcp_asgi_app = http.create_streamable_http_app(
   mcp=mcp_logic,
   stateless=True# <- この一行が全てを解決した
)
# ...

この一行を追加したことで、これまでのすべての問題が嘘のように解決し、ついに Cursor に、ツール実行の正常な応答が返ってきたのです。

完成版:最終的なアーキテクチャとコード

この長い旅路の末に完成した、最終的なファイル構成の要点を改めて紹介します。

  • app.py: FastMCPstateless=True で初期化する。
  • Dockerfile: AWS 公式の aws-lambda-adapter を組み込み、uvicorn を起動する。
  • template.yaml: SAM を使い、ストリーミングを有効化した Function URL を持つコンテナベースの Lambda 関数として、インフラをコードで定義する。

コードベースの詳細はこちらの記事にあります。

この旅から得られた教訓

  • 一次情報を信じよ: エラーメッセージや公式ドキュメントには、必ず解決のヒントが隠されている。
  • 問題を切り分けよ: 「素の FastAPI で試す」のように、ブラックボックスを一つずつ排除していくことが、複雑な問題の解決を早める。
  • 実行環境を理解せよ: サーバーレスの「ステートレス」や「ライフサイクル」といった基本思想を理解しないと、必ず壁にぶつかる。
  • 諦めない: エラーは敵ではなく、正しい道へと導いてくれる道標である。

この長いトラブルシューティングの記録が、これからサーバーレスの荒波に乗り出す、あなたの旅の助けになることを願っています。


参考

AWSMCPPython
スポンサーリンク
ibukishをフォローする
スポンサーリンク
ibukish Lab+
タイトルとURLをコピーしました