概要
前回、ローカル実行を諦めてAzure Functions上にMCPサーバーを直接デプロイし、hello_mcpという単一のツールをGitHub Copilot Chatから呼び出せるところまで確認しました。
今回はその続き。ツールを2つに増やしたら、片方しか呼ばれなくなるという新しい沼にハマりました。
この記事は、その沼から抜け出すまでの記録です。
前提
環境は前回とまったく同じです。詳細は前回記事を参照してください。
- Azure Function:
{作成したfunction名}(Flex Consumption・Python) - VS Code拡張機能:GitHub Copilot Chat(Agentモード)
-
.vscode/mcp.jsonにリモートMCPサーバーを登録済み
目標
複数のMCPツールを、意図通りに使い分けさせたい!!!
課題
hello_mcpに加えて、パラメータ付きのechoツールを追加してみました。
@app.mcp_tool_trigger(
arg_name="context",
tool_name="echo",
description="渡されたメッセージをそのまま返す",
tool_properties=echo_tool_properties,
)
def echo_tool(context) -> str:
content = json.loads(context)
message = content["arguments"]["message"]
return f"エコー: {message}"
デプロイして、いざCopilot Chatから呼び出すぞ!!!
と思いきや・・・
echoツールがそもそも反応しない。
VS Codeを再読み込みしたら呼ばれるようにはなったのですが(MCPクライアントがツール一覧をキャッシュしていたのが原因でした)、今度は別の問題が発覚しました。
何を聞いても、ほぼechoしか呼ばれない・・・
hello_mcpを呼びたくて色々話しかけてみても、20回に1回くらいしかhello_mcpが選ばれません。ほとんどのケースでCopilotがechoの方を選んでしまいます。
「echoのmessageプロパティにisRequired: trueを付けたのが悪いのでは?」と疑い、hello_mcp側にも同じ構造でダミーの任意プロパティを足してみましたが、効果なし・・・。
ちなみに、isRequiredは「呼ぶと決まった後に引数が必須かどうか」を決めるフラグであって、「どちらのツールを呼ぶか」の判断材料ではありません(当たり前ですが、疑わずにいられませんでした)。
isRequiredは無罪でした。
原因なんだろう・・・
原因を切り分けていくと、思い当たったのはツールの設計そのものでした。
-
hello_mcp:引数なし。descriptionは「成功メッセージを返す」という、いつ使うべきかが曖昧な内容 -
echo:messageという汎用的な文字列を受け取る。ユーザーが何か発言すると、それをmessageとみなせば大抵の発言に「使えそう」と判断されてしまう
つまり比較対象としてフェアじゃなかったんです。
片方は用途が曖昧、もう片方は何にでも当てはまる。
これでは使い分けの検証になりません。
そこで・・・
相反する(二項対立の)用途を持つツールを2つ用意すれば
Copilotがどちらを正しく呼んだか一目でわかる。
そう思い立ち、題材をシンプルなクイズ形式に変えました。
- 「好きな食べ物は?」と聞かれたら
favorite_foodツールが「すき焼き・フレンチトースト」と答える - 「嫌いな食べ物は?」と聞かれたら
disliked_foodツールが「かぼちゃの煮付け」と答える
引数なし、用途は完全に対(好き/嫌い)、descriptionも「いつ呼ぶべきか」を明示。これなら比較しやすいはず!!!!
確認方法
Azure Portalの画面を2つ並べてログを見比べ
ゴールイメージ
起動方法と成功画面
GitHub Copilot Chatで「好きな食べ物は?」「嫌いな食べ物は?」とそれぞれ聞くと、対応するツールが呼ばれ、Azure Portalのログにも実行結果が残ります。
Azure Portalで対象の2つの関数(favorite_food_tool / disliked_food_tool)のログ画面をそれぞれ別ウィンドウで開き、横に並べて見てみました。
片方に話しかけて、片方のログにだけ実行結果が流れる・・・というのを画面分割で見比べると、ちゃんと使い分けられていることが一発でわかります。
(これ、地味に我ながら、いい比較方法だなと思いました)
試したこと
成功例
ざっくり
- 曖昧な単一ツール(
hello_mcp)+汎用的な引数を持つツール(echo)の組み合わせは、Copilotの選択が偏りやすい - 用途が完全に相反する(好き/嫌い)2ツールに設計し直したところ、意図通りに使い分けられるようになった
- Azure Portalで2つの関数のログ画面を並べて見ると、使い分けの検証がしやすい
詳細
最終的なコードは以下の通りです。
import azure.functions as func
import logging
import json
app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION)
@app.mcp_tool_trigger(
arg_name="context",
type="mcpToolTrigger",
tool_name="favorite_food",
description=(
"ユーザーから「好きな食べ物は?」「好物は何?」のように、"
"好きな食べ物・好物について尋ねられたときに呼び出すツール。"
"引数は不要。好きな食べ物を固定で返す。"
),
toolProperties="[]",
)
def favorite_food(context) -> str:
try:
content = json.loads(context)
called_tool_name = content.get("name")
logging.info(f"[favorite_food] 呼び出されたツール名: {called_tool_name}")
except (TypeError, json.JSONDecodeError):
logging.warning("[favorite_food] contextのJSONパースに失敗しました")
result = "すき焼き・フレンチトースト"
logging.info(f"[favorite_food] 返す内容: {result}")
return result
@app.mcp_tool_trigger(
arg_name="context",
type="mcpToolTrigger",
tool_name="disliked_food",
description=(
"ユーザーから「嫌いな食べ物は?」「苦手な食べ物は?」のように、"
"嫌いな食べ物・苦手な食べ物について尋ねられたときに呼び出すツール。"
"引数は不要。嫌いな食べ物を固定で返す。"
),
toolProperties="[]",
)
def disliked_food(context) -> str:
try:
content = json.loads(context)
called_tool_name = content.get("name")
logging.info(f"[disliked_food] 呼び出されたツール名: {called_tool_name}")
except (TypeError, json.JSONDecodeError):
logging.warning("[disliked_food] contextのJSONパースに失敗しました")
result = "かぼちゃの煮付け"
logging.info(f"[disliked_food] 返す内容: {result}")
return result
ポイントは2つ。
1つ目は、descriptionに「いつ呼ぶべきか」をはっきり書いたこと。
前回のechoのように、何にでも当てはまりそうな汎用表現は避けています。
2つ目は、ログの設計です。contextをそのままログに出すと、実際にはこんな感じの巨大なJSONが流れてきます(セッションID、ヘッダー、トランスポート情報まで全部入っているので、読みたい情報がどこにあるのか埋もれます)。
{"name":"disliked_food","arguments":{},"sessionid":"...","transport":{"name":"http-streamable","sessionId":"...","properties":{"headers":{...}}}}}
生ログを眺めて「どっちが呼ばれたんだっけ」と目を凝らすのは非効率なので、contextからnameキーだけ取り出してログに出すようにしました。
content = json.loads(context)
called_tool_name = content.get("name")
logging.info(f"呼び出されたツール名: {called_tool_name}")
これで、Azure Portalのログ画面を見た瞬間に「今どっちが呼ばれたか」が一目でわかるようになります。
躓いたこと
Copilotでツール名を指定した話
hello_mcp ツールを呼ぶために、Copilotに一生懸命「 hello_mcp を使って回答してください」や「回答だけください」などと頑張っていました。
ですが、当然 echo ツールの方が強いので、そのまま返してきて悪戦苦闘しました。
(まぁそれはそうだよねって話)
isRequiredなのかと思ったら違った話
前述の通りです。isRequiredは引数の必須/任意を決めるだけで、ツール選択のロジックには関与していません。ここで足止めを食ったのは完全に自分の思い込みでした(気持ちわかりません?)。
descriptionを工夫してもハズレる話
hello_mcpとechoのdescriptionを「いつ呼ぶべきか」がわかる文章に書き直しても、しばらくはhello_mcpが選ばれる確率は大して改善しませんでした。
ここについては、Copilot側のツール選択ロジック(モデルの性能)自体の限界という可能性も否定できません。
最終的に「用途を完全に相反させる」というアプローチに切り替えたことで解決しましたが、descriptionの書き方だけでどこまで制御できるかは、正直まだ手探りです。
まとめ
- ツールを複数用意するときは、用途が完全に相反する(二項対立の)ペアで設計すると、使い分けの検証がしやすい
- Azure Portalで対象の関数のログ画面を2つ並べて(画面分割 or 別ウィンドウ)見比べると、どちらが呼ばれたか一目瞭然
-
contextを生のままログに出すと情報過多で読みにくいので、nameなど必要なキーだけ取り出してログ設計するのがおすすめ
相反する用途のツールを用意し、ログ画面を並べて見比べれば、Copilotがどちらを呼んだか一目でわかる。
isRequiredのような属性を疑う前に、まずツール同士の用途が本当に区別できる設計になっているか、を見直すのが近道かもしれません。
