[アップデート] Amazon Bedrock AgentCore RuntimeがStateful MCPサーバーに対応しました

[アップデート] Amazon Bedrock AgentCore RuntimeがStateful MCPサーバーに対応しました

2026.03.12

はじめに

こんにちは、スーパーマーケットのラ・ムーが大好きなコンサル部の神野(じんの)です。

直近のアップデートでAmazon Bedrock AgentCore Runtimeが Stateful MCP サーバーに対応しました!

https://aws.amazon.com/jp/about-aws/whats-new/2026/03/amazon-bedrock-agentcore-runtime-stateful-mcp/

最初どういったアップデート?と思ったのですが、MCPの仕様としてElicitation(対話的な入力収集)、Sampling(LLMへのテキスト生成依頼)、Progress Notifications(進捗通知)が定義されていて、これがAgentCoreでも対応したよーといった内容になっています。なるほど・・・

それぞれの機能を深掘りすると下記のようなことが実現できます。
ステートフルなためよりインタラクティブなアクションが可能になるって感じですね。

機能 概要
Elicitation サーバーからクライアントに対して対話的にユーザー入力を要求する
Sampling サーバーがクライアント側のLLMにテキスト生成を依頼する
Progress Notifications 長時間処理の進捗をリアルタイムにクライアントへ通知する

https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation

https://modelcontextprotocol.io/specification/2025-11-25/client/sampling

https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress

これらの機能を使うと、例えば航空券予約を想定した時に以下のようなフローが組めるのかなと考えました。今回のElicitation、Sampling、Progress Notificationsを使う前提です。

上記フローのようにMCPサーバーは業務ロジックに集中して、ユーザーとの対話やLLM推論はクライアント側に任せる、という分け方がやりやすくなりそうです。
今までなら選択肢付きの対話UIやセッション管理を個別に組む場面もありましたが、この仕組みを活用することでサーバー側の実装はシンプルになったりするのかなと感じました。

実際の挙動がどこまで噛み合うのかは、手を動かして確かめたくなりますね。

今回はクライアントをAIエージェントではなくシンプルなプログラムにして、公式の Stateful MCP server features のサンプルを土台にしつつ、行き先やデータを国内旅行向けに置き換えながら試してみます!

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/mcp-stateful-features.html

前提

今回はPythonとパッケージマネージャーにuvを使いました。使用したバージョンは下記です。

  • Python 3.13.11
  • uv 0.9.26

今回使用したライブラリのバージョンは下記です。

ライブラリ バージョン
fastmcp 3.1.0
mcp 1.26.0
boto3 1.42.65
bedrock-agentcore-starter-toolkit 0.3.2

uvでプロジェクトの初期化および必要なライブラリをインストールします。

セットアップ
# プロジェクト初期化
uv init --no-readme
uv add fastmcp mcp boto3

# Starter Toolkitのインストール
uv add bedrock-agentcore-starter-toolkit

実装

MCPサーバーの実装

まずはFastMCP で 国内旅行プランナーのMCP Serverを実装します。Elicitation / Sampling / Progress Notifications の機能を使ったツールを実装していきます。

travel_server.py(コード全体)
travel_server.py
"""
国内旅行プランナー - Stateful MCP Server
Elicitation / Sampling / Progress Notifications のデモ
"""
import asyncio
import logging
from fastmcp import FastMCP, Context
from enum import Enum

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("travel-planner")

mcp = FastMCP("Japan-Travel-Planner")

class TripType(str, Enum):
    SOLO = "ひとり旅"
    COUPLE = "カップル"
    FAMILY = "家族旅行"
    FRIENDS = "友人旅行"

DESTINATIONS = {
    "京都": {
        "name": "京都",
        "transport": 14000,
        "hotel": 12000,
        "highlights": ["伏見稲荷大社", "嵐山竹林", "清水寺"],
        "gourmet": ["湯豆腐", "抹茶スイーツ", "にしんそば"],
    },
    "沖縄": {
        "name": "沖縄",
        "transport": 40000,
        "hotel": 10000,
        "highlights": ["美ら海水族館", "古宇利島", "首里城"],
        "gourmet": ["ソーキそば", "タコライス", "海ぶどう"],
    },
    "北海道": {
        "name": "北海道",
        "transport": 35000,
        "hotel": 9000,
        "highlights": ["富良野ラベンダー畑", "小樽運河", "旭山動物園"],
        "gourmet": ["海鮮丼", "ジンギスカン", "スープカレー"],
    },
    "福岡": {
        "name": "福岡",
        "transport": 22000,
        "hotel": 8000,
        "highlights": ["太宰府天満宮", "中洲屋台", "糸島"],
        "gourmet": ["博多ラーメン", "もつ鍋", "明太子"],
    },
}

@mcp.tool()
async def plan_trip(ctx: Context) -> str:
    """
    国内旅行プランを作成(全MCP機能を使用):
    1. Elicitation - 旅行の希望をヒアリング
    2. Progress - 交通手段・宿泊先の検索進捗
    3. Sampling - AIによるおすすめ情報の生成
    """
    # ---- Phase 1: Elicitation ----
    logger.info("[Phase 1] Elicitation 開始 - 旅行先の質問")
    dest_result = await ctx.elicit(
        message="どこに行きたいですか?\n選択肢: 京都、沖縄、北海道、福岡",
        response_type=str,
    )
    if dest_result.action != "accept":
        logger.info("[Phase 1] ユーザーがキャンセル(旅行先)")
        return "プラン作成をキャンセルしました。"
    dest_key = dest_result.data.strip()
    dest = DESTINATIONS.get(dest_key, DESTINATIONS["京都"])
    logger.info(f"[Phase 1] 旅行先: {dest['name']}")

    type_result = await ctx.elicit(
        message="旅行のタイプは?\n1. ひとり旅\n2. カップル\n3. 家族旅行\n4. 友人旅行",
        response_type=TripType,
    )
    if type_result.action != "accept":
        logger.info("[Phase 1] ユーザーがキャンセル(旅行タイプ)")
        return "プラン作成をキャンセルしました。"
    trip_type = type_result.data.value if hasattr(type_result.data, "value") else type_result.data
    logger.info(f"[Phase 1] 旅行タイプ: {trip_type}")

    days_result = await ctx.elicit(
        message="何泊しますか?(1〜7泊)",
        response_type=int,
    )
    if days_result.action != "accept":
        return "プラン作成をキャンセルしました。"
    days = max(1, min(7, days_result.data))

    travelers_result = await ctx.elicit(
        message="何人で行きますか?",
        response_type=int,
    )
    if travelers_result.action != "accept":
        return "プラン作成をキャンセルしました。"
    travelers = travelers_result.data
    logger.info(f"[Phase 1] Elicitation 完了 - {dest['name']}/{trip_type}/{days}泊/{travelers}人")

    # ---- Phase 2: Progress Notifications ----
    logger.info("[Phase 2] Progress Notifications 開始 - 検索処理")
    total_steps = 5
    for step in range(1, total_steps + 1):
        await ctx.report_progress(progress=step, total=total_steps)
        await asyncio.sleep(0.4)

    transport_cost = dest["transport"] * travelers
    hotel_cost = dest["hotel"] * days * ((travelers + 1) // 2)
    total_cost = transport_cost + hotel_cost
    logger.info(f"[Phase 2] Progress 完了 - 費用算出: ¥{total_cost:,}")

    # ---- Phase 3: Sampling ----
    logger.info("[Phase 3] Sampling 開始 - クライアントにAI推論を要求")
    ai_tips = f"{dest['name']}を楽しんでください!"
    try:
        response = await ctx.sample(
            messages=(
                f"{dest['name']}への{trip_type}{travelers}人、{days}泊)のおすすめを"
                f"3つ、60文字以内で簡潔に教えてください。日本語で回答してください。"
            ),
            max_tokens=200,
        )
        if hasattr(response, "text") and response.text:
            ai_tips = response.text
        logger.info(f"[Phase 3] Sampling 完了 - 応答: {ai_tips[:50]}...")
    except Exception as e:
        logger.warning(f"[Phase 3] Sampling 失敗: {e}")
        ai_tips = f"{dest['highlights'][0]}は必見です。{dest['gourmet'][0]}もぜひ!"

    # ---- Final Confirmation ----
    logger.info("[Phase 4] 最終確認 Elicitation")
    confirm = await ctx.elicit(
        message=f"""
========== 旅行プラン概要 ==========
旅行先: {dest['name']}
タイプ: {trip_type}
日程: {days}{days + 1}
人数: {travelers}

費用概算:
  交通費: ¥{transport_cost:,}
  宿泊費: ¥{hotel_cost:,}{(travelers + 1) // 2}部屋 × {days}泊)
  合計: ¥{total_cost:,}

この内容で予約しますか?""",
        response_type=["予約する", "キャンセル"],
    )
    if confirm.action != "accept" or confirm.data == "キャンセル":
        logger.info("[Phase 4] ユーザーがキャンセル")
        return "予約をキャンセルしました。検索結果は24時間保存されます。"

    logger.info(f"[Phase 4] 予約確定 - {dest['name']}/{trip_type}/{days}泊/{travelers}人/¥{total_cost:,}")
    highlights_str = "\n".join(f"  - {h}" for h in dest["highlights"])
    gourmet_str = "\n".join(f"  - {g}" for g in dest["gourmet"])
    return f"""
{'=' * 50}
予約が確定しました!
{'=' * 50}
予約番号: TRV-{ctx.session_id[:8].upper()}

旅行先: {dest['name']}
日程: {days}{days + 1}日 / {travelers}
タイプ: {trip_type}

交通費: ¥{transport_cost:,}
宿泊費: ¥{hotel_cost:,}{(travelers + 1) // 2}部屋 × {days}泊)
合計: ¥{total_cost:,}

おすすめスポット:
{highlights_str}

ご当地グルメ:
{gourmet_str}

AIからのおすすめ:
{ai_tips}
{'=' * 50}
"""

if __name__ == "__main__":
    mcp.run(
        transport="streamable-http",
        host="0.0.0.0",
        port=8000,
        stateless_http=False,
    )

ポイントをピックアップして説明します。

Elicitation:対話的にユーザー入力を収集

travel_server.py
dest_result = await ctx.elicit(
    message="どこに行きたいですか?\n選択肢: 京都、沖縄、北海道、福岡",
    response_type=str,
)
if dest_result.action != "accept":
    return "プラン作成をキャンセルしました。"

ctx.elicit() を呼び出すと、サーバーからクライアントに対して入力を要求します。response_type には strintEnum、リスト(選択肢)などを指定でき、クライアントからの応答は ElicitResult として返ります。

action"accept" 以外の場合はキャンセルとして処理します。

Progress Notifications:進捗の可視化

travel_server.py
total_steps = 5
for step in range(1, total_steps + 1):
    await ctx.report_progress(progress=step, total=total_steps)
    await asyncio.sleep(0.4)

ctx.report_progress() で現在の進捗と全体のステップ数を通知します。
クライアント側でプログレスバーなどのUIに変換して表示する想定です。

Sampling:LLMにテキスト生成を依頼

travel_server.py
response = await ctx.sample(
    messages=(
        f"{dest['name']}への{trip_type}{travelers}人、{days}泊)のおすすめを"
        f"3つ、60文字以内で簡潔に教えてください。日本語で回答してください。"
    ),
    max_tokens=200,
)

ctx.sample() を使うと、サーバー側からクライアントで使用できるLLMに対してテキスト生成を依頼できます。
今回の構成だと、MCPサーバーが直接LLMを呼ぶというより、クライアント側で用意した Bedrock 呼び出しに乗せて推論しているイメージです。

サーバー起動設定

travel_server.py
mcp.run(
    transport="streamable-http",
    host="0.0.0.0",
    port=8000,
    stateless_http=False,  # これが重要
)

ドキュメントでは、Elicitation / Sampling / Progress Notifications を使うには stateless_http=False にすると書かれています。ステートフルにするためにFalseを設定する形ですね。

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/mcp-stateful-features.html

各フェーズに logger.info() を仕込んでおくと、AgentCore Runtimeにデプロイした際にCloudWatch Logsでフェーズの流れを追えるようになります。HTTPレベルのログだけだとどのMCP機能が動いているのかわからないので、入れて挙動を確認してました。

テストクライアントの実装

Stateful MCPではクライアント側にもハンドラの実装が必要です。
Elicitation、Sampling、Progressそれぞれのハンドラを用意します。

test_client.py(コード全体)
test_client.py
"""
国内旅行プランナー - テストクライアント
Elicitation / Sampling / Progress のハンドラを実装

環境変数:
  LOCAL_TEST=true (default) : ローカルサーバーに接続
  LOCAL_TEST=false          : AgentCore Runtime に接続(AGENT_ARN, BEARER_TOKEN 必須)
  USE_BEDROCK=true          : Sampling に Amazon Bedrock を使用
  BEDROCK_MODEL_ID          : Bedrock モデルID(デフォルト: us.anthropic.claude-haiku-4-5-20251001-v1:0)
"""
import asyncio
import os
import sys
import typing
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.client.elicitation import ElicitResult
from mcp.types import CreateMessageResult, TextContent

# --- Elicitation ハンドラ ---

def _extract_options(response_type) -> list[str] | None:
    """response_type から選択肢リストを取り出す。
    fastmcp は ["予約する", "キャンセル"] を Literal["予約する", "キャンセル"] に変換して渡してくる。
    """
    if isinstance(response_type, list):
        return response_type
    if typing.get_origin(response_type) is typing.Literal:
        args = typing.get_args(response_type)
        if args:
            return list(args)
    return None

def _prompt_choice(options: list[str]) -> str:
    """番号で選択させる。不正入力はリトライ。"""
    for i, opt in enumerate(options, 1):
        print(f"    {i}. {opt}")
    while True:
        raw = input("    番号を選択: ").strip()
        try:
            idx = int(raw)
            if 1 <= idx <= len(options):
                return options[idx - 1]
        except ValueError:
            pass
        print(f"    ※ 1〜{len(options)} の番号を入力してください")

def _prompt_int(label: str = "回答(数字)") -> int:
    """整数を入力させる。不正入力はリトライ。"""
    while True:
        raw = input(f"    {label}: ").strip()
        try:
            return int(raw)
        except ValueError:
            print("    ※ 数字を入力してください")

async def elicit_handler(message, response_type, params, ctx):
    print(f"\n>>> サーバーからの質問: {message}")
    try:
        options = _extract_options(response_type)
        if options:
            response = _prompt_choice(options)
        elif response_type is int:
            response = _prompt_int()
        else:
            response = input("    回答: ").strip()
        return ElicitResult(action="accept", content={"value": response})
    except (KeyboardInterrupt, EOFError):
        print("\n    (キャンセルされました)")
        return ElicitResult(action="decline", content=None)

# --- Sampling ハンドラ ---

def _extract_prompt_text(messages) -> str:
    """SamplingMessage のリストからプロンプトテキストを抽出"""
    if isinstance(messages, str):
        return messages
    if isinstance(messages, list):
        parts = []
        for msg in messages:
            if hasattr(msg, "content") and hasattr(msg.content, "text"):
                parts.append(msg.content.text)
            else:
                parts.append(str(msg))
        return "\n".join(parts)
    return str(messages)

def _invoke_bedrock(prompt: str) -> str:
    """Amazon Bedrock で推論を実行"""
    import boto3
    model_id = os.getenv("BEDROCK_MODEL_ID", "us.anthropic.claude-haiku-4-5-20251001-v1:0")
    client = boto3.client("bedrock-runtime")
    response = client.converse(
        modelId=model_id,
        messages=[{"role": "user", "content": [{"text": prompt}]}],
        inferenceConfig={"maxTokens": 200},
    )
    return response["output"]["message"]["content"][0]["text"]

async def sampling_handler(messages, params, ctx):
    prompt_text = _extract_prompt_text(messages)
    print(f"\n>>> AIサンプリングリクエスト")
    print(f"    プロンプト: {prompt_text[:120]}...")

    use_bedrock = os.getenv("USE_BEDROCK", "false").lower() == "true"
    try:
        if use_bedrock:
            print("    (Bedrock で推論中...)")
            ai_response = _invoke_bedrock(prompt_text)
            print(f"    Bedrock応答: {ai_response}")
        else:
            ai_response = input("    AI応答を入力(Enterでデフォルト): ").strip()
            if not ai_response:
                ai_response = "1. 人気スポットは朝一で 2. ご当地グルメを堪能 3. 地元の人おすすめの穴場へ"
    except Exception as e:
        print(f"    ※ Sampling エラー: {e}")
        ai_response = "おすすめ情報を取得できませんでした。"

    return CreateMessageResult(
        role="assistant",
        content=TextContent(type="text", text=ai_response),
        model="bedrock" if use_bedrock else "manual",
        stopReason="endTurn",
    )

# --- Progress ハンドラ ---

async def progress_handler(progress, total, message):
    pct = int((progress / total) * 100) if total else 0
    bar = "#" * (pct // 5) + "-" * (20 - pct // 5)
    print(f"\r    進捗: [{bar}] {pct}%", end="", flush=True)
    if progress == total:
        print(" 完了!")

# --- メイン ---

async def main():
    local_test = os.getenv("LOCAL_TEST", "true").lower() == "true"

    if local_test:
        url = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8000/mcp"
        headers = {}
    else:
        agent_arn = os.getenv("AGENT_ARN")
        token = os.getenv("BEARER_TOKEN")
        if not agent_arn or not token:
            print("ERROR: AGENT_ARN / BEARER_TOKEN が未設定です")
            sys.exit(1)
        encoded_arn = agent_arn.replace(":", "%3A").replace("/", "%2F")
        endpoint = os.getenv(
            "MCP_ENDPOINT",
            f"https://bedrock-agentcore.{os.getenv('AWS_REGION', 'us-west-2')}.amazonaws.com",
        )
        url = f"{endpoint}/runtimes/{encoded_arn}/invocations?qualifier=DEFAULT"
        headers = {"Authorization": f"Bearer {token}"}

    transport = StreamableHttpTransport(url=url, headers=headers)
    client = Client(
        transport,
        elicitation_handler=elicit_handler,
        sampling_handler=sampling_handler,
        progress_handler=progress_handler,
    )

    try:
        await client.__aenter__()
    except Exception as e:
        print(f"\nERROR: サーバーへの接続に失敗しました: {e}")
        sys.exit(1)

    try:
        print("\nplan_trip ツールのテスト...")
        print("(Elicitation → Progress → Sampling の全フローを実行)\n")
        result = await client.call_tool("plan_trip", {})
        print("\n" + "=" * 60)
        print("結果:")
        print("=" * 60)
        print(result.content[0].text)
    except KeyboardInterrupt:
        print("\n\n中断されました")
    except Exception as e:
        print(f"\nERROR: {e}")
    finally:
        try:
            await client.__aexit__(None, None, None)
        except Exception:
            pass

if __name__ == "__main__":
    asyncio.run(main())

クライアント側では3つのハンドラを実装しました。

elicit_handler はサーバーからの質問に対してユーザー入力を処理します。Fastmcp は response_type=["予約する", "キャンセル"] のようなリストを Literal 型に変換して渡してくるので、typing.get_origin() で検出して番号選択に変換しています。

test_client.py
def _extract_options(response_type) -> list[str] | None:
    if isinstance(response_type, list):
        return response_type
    if typing.get_origin(response_type) is typing.Literal:
        args = typing.get_args(response_type)
        if args:
            return list(args)
    return None

messagesSamplingMessage オブジェクトのリストで渡ってくるので、.content.text を取り出して使います。

test_client.py
def _invoke_bedrock(prompt: str) -> str:
    import boto3
    model_id = os.getenv("BEDROCK_MODEL_ID", "us.anthropic.claude-haiku-4-5-20251001-v1:0")
    client = boto3.client("bedrock-runtime")
    response = client.converse(
        modelId=model_id,
        messages=[{"role": "user", "content": [{"text": prompt}]}],
        inferenceConfig={"maxTokens": 200},
    )
    return response["output"]["message"]["content"][0]["text"]

progress_handler はプログレスバーを表示するだけのシンプルな実装です。

test_client.py
async def progress_handler(progress, total, message):
    pct = int((progress / total) * 100) if total else 0
    bar = "#" * (pct // 5) + "-" * (20 - pct // 5)
    print(f"\r    進捗: [{bar}] {pct}%", end="", flush=True)
    if progress == total:
        print(" 完了!")

AgentCore Runtimeへのデプロイ

実装ができたので、AgentCore Runtimeにデプロイして動かしていきます。デプロイ手順は公式ドキュメントにも記載されています。

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-mcp.html

Cognitoユーザープールの作成

AgentCore Runtimeへの認証にはJWTトークンが必要です。starter toolkit に Cognito セットアップ用のコマンドsetup-cognitoが用意されているので、これを使います。
こんなコマンドあるんだと私も初めて知りました。

Cognitoセットアップ
agentcore identity setup-cognito

完了すると .agentcore_identity_user.env にクレデンシャル情報が保存されます。

設定(configure)

Cognitoの情報を使って、認証付きでAgentCore Runtimeを設定します。

設定コマンド
agentcore configure \
  -e travel_server.py \
  -p MCP \
  -n japan_travel_planner \
  -ac '{"customJWTAuthorizer": {"allowedClients": ["<client_id>"], "discoveryUrl": "<discovery_url>"}}'
オプション 説明
-e エントリポイントのPythonファイル
-p MCP MCPプロトコルを指定
-n エージェント名
-ac OAuth認証設定(CognitoのクライアントIDとOIDCディスカバリURL)

<client_id><discovery_url>setup-cognito で生成された .agentcore_identity_user.env に記載されている値を使います。

対話形式で設定を進めていき、基本デフォルトの設定で問題ないのですが今回はコンテナデプロイを選択する点だけ注意です。

デプロイ

デプロイコマンド
agentcore deploy

ECRリポジトリの作成、Dockerイメージのビルド&プッシュ、AgentCore Runtimeの作成まで自動で行われます。デプロイが完了するとAgent ARNが出力されるので控えておきます。

Bearerトークンの取得

デプロイしたサーバーにアクセスするためのBearerトークンもStarter toolkit のコマンドで取得できます。

トークン取得
# .agentcore_identity_user.env を環境変数に読み込み
export $(grep -v '^#' .agentcore_identity_user.env | xargs)

# Cognitoからアクセストークンを取得
export BEARER_TOKEN=$(agentcore identity get-cognito-inbound-token)

setup-cognito で保存された環境変数ファイルをそのまま読み込んで get-cognito-inbound-token に渡すだけでトークンが取れます。こちらもこんなコマンドあったんだと初知りでしたが、Pythonスクリプトを自前で書く必要がないのは楽ですね。

動作確認

デプロイしたMCPサーバーに対してテストクライアントを実行してみます。USE_BEDROCK=true を付けると、Sampling の応答を Amazon Bedrockで生成します。

実行コマンド
export AGENT_ARN='arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/japan_travel_planner'
USE_BEDROCK=true LOCAL_TEST=false uv run python test_client.py
実行結果
[1] plan_trip ツールのテスト...
    (Elicitation Progress Sampling の全フローを実行)

>>> サーバーからの質問: どこに行きたいですか?
選択肢: 京都、沖縄、北海道、福岡
    回答: 京都

>>> サーバーからの質問: 旅行のタイプは?
1. ひとり旅
2. カップル
3. 家族旅行
4. 友人旅行
    回答: ひとり旅

>>> サーバーからの質問: 何泊しますか?(1〜7泊)
    回答: 2

>>> サーバーからの質問: 何人で行きますか?
    回答: 1
    進捗: [####################] 100% 完了!

>>> AIサンプリングリクエスト
    プロンプト: 京都へのひとり旅(1人、2泊)のおすすめを3つ、60文字以内で簡潔に教えてください。日本語で回答してください。...
    (Bedrock で推論中...)
    Bedrock応答: # 京都ひとり旅のおすすめ3つ

1. **伏見稲荷大社**
千本鳥居の壮観さ。朝早く訪れると人が少なく、静寂に包まれた神秘的な雰囲気を満喫できます。

2. **哲学の道**
桜や新緑が美しい散歩道。カフェも多く、ゆったり自分のペースで散策するひとり旅に最適です。

3. **清水寺周辺**
古都の景観が凝縮。参拝後、門前町で食べ歩きしたり、寺社仏閣めぐりを自由

>>> サーバーからの質問:
========== 旅行プラン概要 ==========
旅行先: 京都
タイプ: ひとり旅
日程: 2泊3日
人数: 1人

費用概算:
  交通費: ¥14,000
  宿泊費: ¥24,000(1部屋 × 2泊)
  合計: ¥38,000

この内容で予約しますか?
    回答: 予約する

============================================================
結果:
============================================================

==================================================
予約が確定しました!
==================================================
予約番号: TRV-09A5D4B1

旅行先: 京都
日程: 2泊3日 1人
タイプ: ひとり旅

交通費: ¥14,000
宿泊費: ¥24,000(1部屋 × 2泊)
合計: ¥38,000

おすすめスポット:
  - 伏見稲荷大社
  - 嵐山竹林
  - 清水寺

ご当地グルメ:
  - 湯豆腐
  - 抹茶スイーツ
  - にしんそば

AIからのおすすめ:
# 京都ひとり旅のおすすめ3つ

1. **伏見稲荷大社**
千本鳥居の壮観さ。朝早く訪れると人が少なく、静寂に包まれた神秘的な雰囲気を満喫できます。

2. **哲学の道**
桜や新緑が美しい散歩道。カフェも多く、ゆったり自分のペースで散策するひとり旅に最適です。

3. **清水寺周辺**
古都の景観が凝縮。参拝後、門前町で食べ歩きしたり、寺社仏閣めぐりを自由
==================================================

AgentCore Runtime上でも、Elicitation → Progress → Sampling → 最終確認までの流れは確認できました!
ステートフルにセッションが維持されるので、途中で状態が失われることなく、一連のやり取りをそのまま継続されたのを確認しました!

実行後に Session termination failed: 404 というログが出ることがありました。
MCP の Session Management の仕様では、セッション終了後や期限切れのタイミングでは 404 が返ることがあると説明されています。

今回の終了時ログもその系統かなと思っていますが、少なくとも今回試した範囲では、ツール呼び出し自体には影響はありませんでした。

CloudWatch Logsでの確認

AgentCore Runtime上ではサーバーの標準出力がCloudWatch Logsに送られます。travel_server.py に仕込んだログを抜き出すと、各MCPフェーズの流れが確認できます。

CloudWatch Logs(アプリケーションログ抜粋)
05:03:49 [INFO] [Phase 1] 旅行先: 京都
05:03:54 [INFO] [Phase 1] 旅行タイプ: ひとり旅
05:04:01 [INFO] [Phase 1] Elicitation 完了 - 京都/ひとり旅/2泊/1人
05:04:01 [INFO] [Phase 2] Progress Notifications 開始 - 検索処理
05:04:03 [INFO] [Phase 2] Progress 完了 - 費用算出: ¥38,000
05:04:03 [INFO] [Phase 3] Sampling 開始 - クライアントにAI推論を要求
05:04:08 [INFO] [Phase 3] Sampling 完了 - 応答: AIによる入力...
05:04:08 [INFO] [Phase 4] 最終確認 Elicitation
05:04:13 [INFO] [Phase 4] 予約確定 - 京都/ひとり旅/2泊/1人/¥38,000

Elicitation → Progress → Sampling → 最終確認の各フェーズが時系列で追えていますね!

セッション管理について

Stateful MCP では、初期化時に Mcp-Session-Id を受け取り、以降のリクエストで同じセッションIDを引き回します。

MCP の stateful features の説明ではサーバーが initialize 時にこのヘッダーを返す形ですが、AgentCore Runtime の一般ガイドではプラットフォーム側が Mcp-Session-Id を補う説明もあります。今回はそのあたりのセッションID管理を自前で書かなくても、FastMCP のクライアント実装に任せたままで問題なく動きました。

In stateful mode, the server returns an Mcp-Session-Id header during the initialize call. Clients must include this session ID in subsequent requests to maintain session context. If the server terminates or the session expires, requests may return a 404 error, and clients must re-initialize to obtain a new session ID. For more details, see Session Management in the MCP specification.

クリーンアップ

検証が終わったら、不要なリソースは削除しておきます。Runtime 側は agentcore destroy で片付けられますし、Identity も使っている場合は agentcore identity cleanup で Cognito 関連までまとめて掃除できます。

今回はCognito関連のコマンドで驚いていましたが、まさかお掃除用のコマンドもあるとはビックリしました。

リソース削除
agentcore identity cleanup
agentcore destroy --agent japan_travel_planner --force

おわりに

Elicitation / Sampling / Progress NotificationsというMCP仕様の機能が、AgentCore Runtime上で実際に動くところまで確認できました。

Elicitationは、ツール実行の途中でユーザーに質問を投げかけられるので、対話しながら段階的に情報を集めるワークフローを組めるのが面白いなと思いました。AIエージェントやUIと組み合わせて実装してみたいですね。

どちらかというと、今回のアップデートはAgentCoreの作りというよりはMCP自体の仕様の話で難しいところもありつつ勉強になるなと感じました・・・!まだ腑に落ちていない箇所もあるので、もっと触って理解していきたいですね!

本記事が少しでも参考になりましたら幸いです。最後までご覧いただきありがとうございました!

この記事をシェアする

FacebookHatena blogX

関連記事