MCPの(ほぼ)全機能を実装したズンドコMCPサーバーをFastMCP 2.0で実装し、MCPを完全に理解した話の続き。 前回の記事はこれ

今回は主にMCPクライアント機能のElicitationの実装について。

なお、この記事には生成AIの出力が含まれる。

ズンドコMCPサーバー・クライアント

MCPのいろいろな機能を試すため、ズンドコMCPサーバーとズンドコクライアントをFastMCP 2.0で開発した。

前回の記事では以下のMCPサーバー機能の実装について書いた。

  • MCPサーバー機能
    • Tools
      • get_zundoko: 「ズン」か「ドコ」をランダムにひとつ生成する。
    • Resources
      • zundoko://history: get_zundokoで生成したズンドコの履歴。
        • ズンドコ履歴の変更通知に対応。
    • Resource Templates
      • zundoko://history/{index}: ズンドコ履歴内の特定の回次のズンドコ。
    • Prompts
      • explain_zundoko_kiyoshi: ズンドコキヨシのやりかたを説明するプロンプト。
    • Logging
      • Tools処理中のログを送信する。

今回の記事では以下の実装について書く。

  • MCPサーバー機能
    • Tools
      • check_kiyoshi: 「キ・ヨ・シ!」条件を満たすかチェックする。
      • reset_zundoko_kiyoshi: ズンドコ履歴とキヨシ状態をリセットする。
    • Resources
      • zundoko://kiyoshi: 「キ・ヨ・シ!」をしたことがあれば、その記録。(i.e. キヨシ状態)
        • キヨシ状態の変更通知に対応。
  • MCPクライアント機能
    • Elicitation
      • check_kiyoshi時にユーザーが「キ・ヨ・シ!」コールできる。

書いたソースはGitHubに置いてある。

FastMCPでElicitationを実装

ElicitationはMCPクライアントの機能で、MCPサーバーから受信したリクエストに応じてユーザーに入力を求めて、入力結果をサーバーに返すことができる。 ズンドコMCPサーバー・ズンドコクライアントにおいては、この機能をユーザーに「キ・ヨ・シ!」コールさせるために使う。

ズンドコMCPサーバー側の実装

「キ・ヨ・シ!」は、「ズン」、「ズン」、「ズン」、「ズン」、「ドコ」の後にコールするので、いったんElicitationおいておいて、この「キ・ヨ・シ!」条件をズンドコ履歴が満たしているかを判定するだけのMCPツールcheck_kiyoshiを実装する。

@mcp.tool
async def check_kiyoshi(ctx: Context) -> str:
    """
    Checks if the zundoko history matches the winning pattern (Zun Zun Zun Zun Doko)
    and elicits "Ki-yo-shi!" from the user if it does.
    """
    history_count = len(zundoko_history)

    if history_count < 5:
        return f"History has only {history_count} item(s), need at least 5 to check for the pattern."

    if zundoko_history[-5:] == ["Zun", "Zun", "Zun", "Zun", "Doko"]:
        return "Pattern found!"
    else:
        return f"Pattern not found. Last 5 items: {zundoko_history[-5:]}"

これはまだ単に「キ・ヨ・シ!」条件の判定結果を返すだけのツール。 これを拡張して、「キ・ヨ・シ!」条件を満たしたときに即"Pattern found!"を返す代わりに、Elicitationでユーザーから返ってきたコールの内容によってレスポンスを変えるようにする。

Elicitationリクエストは、前回記事のLoggingと同様に、Contextオブジェクトのメソッドで送れる。 Elicitationのメソッドはelicit()で、ユーザーに向けたメッセージと、ユーザーに入力してほしい値の型を指定できる。

Elicitationリクエストに対しては、acceptdeclinecancelの3通りのレスポンスがプロトコルに規定されていて、elicit()の戻り値はそれらに対応したAcceptedElicitation、DeclinedElicitation、CancelledElicitationのいずれかになり、AcceptedElicitationのときだけユーザー入力を受け取れる。

check_kiyoshiツールの処理の中で、「キ・ヨ・シ!」条件を満たしたときにelicit()を呼んで、その戻り値の型によってツールのレスポンスを変えるようにする。

 from fastmcp import FastMCP, Context
+from fastmcp.server.elicitation import (
+    AcceptedElicitation,
+    DeclinedElicitation,
+    CancelledElicitation,
+)

<snip>

     if zundoko_history[-5:] == ["Zun", "Zun", "Zun", "Zun", "Doko"]:
-        return "Pattern found!"
+        elicit_result = await ctx.elicit(
+            "It's time to say Ki-yo-shi!",
+            response_type=str
+        )
+
+        match elicit_result:
+            case AcceptedElicitation(data=response):
+                if "Ki-yo-shi!" == response:
+                    return "Perfect!'"
+                else:
+                    return f"Pattern found! But you said '{response}' instead of 'Ki-yo-shi!'"
+            case DeclinedElicitation():
+                return "Pattern found! You declined to say 'Ki-yo-shi!'"
+            case CancelledElicitation():
+                return "Pattern found! You cancelled Ki-yo-shi!"
     else:
         return f"Pattern not found. Last 5 items: {zundoko_history[-5:]}"

elicit()の結果がAcceptedElicitationの時は、ユーザーの入力値を取得して、入力値がKi-yo-shi!かそれ以外によってツールのレスポンスを変えるようにした。

ズンドコクライアント側の実装

Elicitationリクエストを処理するクライアントは、前回記事のLoggingのクライアントをベースにする。

Elicitationリクエストの処理は、Loggingやリソース変更通知同様に、Clientインスタンスに設定するハンドラ関数に書くことができる。

from fastmcp.client.elicitation import ElicitResult


kiyoshi_answered = False

# ハンドラ関数の実装
async def elicitation_handler(message: str, response_type: type, params, context):
    global kiyoshi_answered

    # Elicitationリクエストのメッセージをコンソールに表示して、ユーザーの入力を受け取る
    user_input = input(f"{message}: ")
    kiyoshi_answered = True

    # ズンドコMCPサーバーにユーザー入力を返す
    return ElicitResult(action="accept", content=response_type(value=user_input))

ハンドラ関数の中身はこんな感じ。今回はレスポンスはaccept一択。

このハンドラ関数は、Clientインスタンスのelicitation_handlerプロパティに設定する。

 async def main():
     async with Client(
         "http://127.0.0.1:8080/mcp",
         log_handler=handle_log,
+        elicitation_handler=elicitation_handler,
     ) as client:
         await client.call_tool("get_zundoko", {})

クライアントの今の実装は、一回get_zundokoツールを呼ぶだけになってるけど、これを一連のズンドコキヨシをするように直す。

         elicitation_handler=elicitation_handler,
     ) as client:
-        await client.call_tool("get_zundoko", {})
+        while True:
+            # 「キ・ヨ・シ!」条件達成まで無限ループ
+            if kiyoshi_answered:
+                break
+
+            try:
+                # ズンドコを一つ取得
+                zundoko = await client.call_tool("get_zundoko", {})
+                print(zundoko.content[0].text)
+
+                # 「キ・ヨ・シ!」条件チェック
+                kiyoshi_check_result = await client.call_tool("check_kiyoshi", {})
+                print(kiyoshi_check_result.content[0].text)
+
+                await asyncio.sleep(1)
+            except Exception as e:
+                print(f"Error: {e}")
+                break

このクライアントを実行すると以下のような出力が得られる。(ログハンドラの出力除く。)

<snip>
Zun
Pattern not found. Last 5 items: ['Zun', 'Doko', 'Zun', 'Zun', 'Zun']
Zun
Pattern not found. Last 5 items: ['Doko', 'Zun', 'Zun', 'Zun', 'Zun']
Doko
It's time to say Ki-yo-shi!:

ここで入力待ちになるので、「キ・ヨ・シ!」コールする。

<snip>
Zun
Pattern not found. Last 5 items: ['Zun', 'Doko', 'Zun', 'Zun', 'Zun']
Zun
Pattern not found. Last 5 items: ['Doko', 'Zun', 'Zun', 'Zun', 'Zun']
Doko
It's time to say Ki-yo-shi!: Ki-yo-shi!
Perfect!'

MCPサーバー側にコールを返せた。

zundoko://kiyoshiリソースとreset_zundoko_kiyoshiツールの実装

「キ・ヨ・シ!」コール済みか(i.e. キヨシ状態)をリソースとして取得したり、その状態やズンドコ履歴をリセットするツールを実装したけど、実装内容は前回の記事と大差ないので割愛。

実装のコミットはこれ