0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【MCPサーバー作成】ツールを増やしたら、Copilotが片方しか呼んでくれなくなった話

0
Posted at

概要

前回、ローカル実行を諦めて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の方を選んでしまいます。

echomessageプロパティにisRequired: trueを付けたのが悪いのでは?」と疑い、hello_mcp側にも同じ構造でダミーの任意プロパティを足してみましたが、効果なし・・・。

ちなみに、isRequiredは「呼ぶと決まった後に引数が必須かどうか」を決めるフラグであって、「どちらのツールを呼ぶか」の判断材料ではありません(当たり前ですが、疑わずにいられませんでした)。

isRequiredは無罪でした。

原因なんだろう・・・

原因を切り分けていくと、思い当たったのはツールの設計そのものでした。

  • hello_mcp:引数なし。descriptionは「成功メッセージを返す」という、いつ使うべきかが曖昧な内容
  • echomessageという汎用的な文字列を受け取る。ユーザーが何か発言すると、それを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)のログ画面をそれぞれ別ウィンドウで開き、横に並べて見てみました。

作成した関数名.png

片方に話しかけて、片方のログにだけ実行結果が流れる・・・というのを画面分割で見比べると、ちゃんと使い分けられていることが一発でわかります。
(これ、地味に我ながら、いい比較方法だなと思いました)


試したこと

成功例

ざっくり

  • 曖昧な単一ツール(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_mcpechoのdescriptionを「いつ呼ぶべきか」がわかる文章に書き直しても、しばらくはhello_mcpが選ばれる確率は大して改善しませんでした。
ここについては、Copilot側のツール選択ロジック(モデルの性能)自体の限界という可能性も否定できません。

最終的に「用途を完全に相反させる」というアプローチに切り替えたことで解決しましたが、descriptionの書き方だけでどこまで制御できるかは、正直まだ手探りです。


まとめ

  • ツールを複数用意するときは、用途が完全に相反する(二項対立の)ペアで設計すると、使い分けの検証がしやすい
  • Azure Portalで対象の関数のログ画面を2つ並べて(画面分割 or 別ウィンドウ)見比べると、どちらが呼ばれたか一目瞭然
  • contextを生のままログに出すと情報過多で読みにくいので、nameなど必要なキーだけ取り出してログ設計するのがおすすめ

相反する用途のツールを用意し、ログ画面を並べて見比べれば、Copilotがどちらを呼んだか一目でわかる。

isRequiredのような属性を疑う前に、まずツール同士の用途が本当に区別できる設計になっているか、を見直すのが近道かもしれません。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?