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のresultを成功と決めない。isErrorと追加入力を分ける受信テスト

0
Last updated at Posted at 2026-10-11

この記事はAIが公開仕様を調べ、独立した小例を作成・実行した技術メモです。仕上げは AI との協働でやっています。著者本人の経験談や会社の実装を再現したものではありません。

resultが返ってきても、成功とは限らない

MCPのtools/callをアプリに取り込むとき、"result" in responseだけで成功フラグを立てると、実行エラーや追加入力待ちまで完了として扱ってしまいます。まず応答の種類を分け、その後に業務データの確認と再実行判断を置きます。

対象はMCP 2026-07-28版です。既存の2025年版の接続へこのまま追加するコードではありません。実装が使う仕様版とSDKの対応を先に確認してください。ここで実行したのは合成応答の分類試験だけで、SDK・サーバー間の相互運用性や実際の業務成功は未検証です。

四つの分岐と、分類できない応答

公式Tools仕様の正常結果、Input Required Tool Results、Error Handlingを根拠に、アプリ側の分類名を次のように置きます。分類名はこの記事の小例のもので、MCPの追加フィールドではありません。

応答の手掛かり 小例の分類 この時点で完了に進めるか
JSON-RPCのerror protocol_error 進めない
resultType: complete、isError: true tool_error 進めない
resultType: input_required input_required 入力の受け渡しが必要
resultType: complete、isError: falseまたは省略 normal_complete 内容の確認へ進む
分岐に使う型が違う、対応外の版など unclassified 成功へ昇格させず調査する

completeは業務成功の証明ではありません。エラーもこの型で返るため、isErrorとの組合せを見ます。追加入力は同じ引数の再送とも違います。応答に対応する入力を渡す処理が必要ですが、小例はその処理を実行しません。

合成応答で誤分類を検出する

SDKなどで応答全体を検証した後に置く、アプリの分岐だけを試験します。この関数はMCPスキーマ検証器ではありません。JSON-RPCのID照合、各contentの型、inputRequestsの詳細、業務のoutputSchemaは別途検証する前提です。無効な応答をすべて検出できるとは主張しません。

以下をresponse_test.pyに保存します。INPUTの内容も実利用者の回答ではなく、この記事で作った値です。

import unittest


def classify(response):
    # Application branch checks, not a full MCP schema validator.
    if ("error" in response) == ("result" in response):
        return "unclassified"
    if "error" in response:
        return "protocol_error"
    result = response["result"]
    if not isinstance(result, dict):
        return "unclassified"
    kind = result.get("resultType")
    if kind == "input_required":
        return "input_required"
    if kind != "complete":
        return "unclassified"
    flag = result.get("isError", False)
    if type(flag) is not bool:
        return "unclassified"
    return "tool_error" if flag else "normal_complete"


def envelope(result):
    return {"jsonrpc": "2.0", "id": 1, "result": result}


def complete(**fields):
    return envelope({"resultType": "complete", "content": [], **fields})


INPUT = envelope({
    "resultType": "input_required",
    "inputRequests": {"choose": {
        "method": "elicitation/create",
        "params": {"mode": "form", "message": "Choose a label",
                   "requestedSchema": {"type": "object", "properties": {
                       "label": {"type": "string"}}, "required": ["label"]}}
    }},
    "requestState": "synthetic-state",
})
PROTOCOL_ERROR = {"jsonrpc": "2.0", "id": 1,
                  "error": {"code": -32602, "message": "Unknown tool"}}
CASES = [
    ("normal_false", complete(isError=False), "normal_complete"),
    ("normal_omitted", complete(), "normal_complete"),
    ("tool_error", complete(isError=True), "tool_error"),
    ("additional_input", INPUT, "input_required"),
    ("protocol_error", PROTOCOL_ERROR, "protocol_error"),
    ("string_flag", complete(isError="false"), "unclassified"),
    ("integer_flag", complete(isError=0), "unclassified"),
    ("null_flag", complete(isError=None), "unclassified"),
    ("unknown_result_type", envelope({"resultType": "future"}), "unclassified"),
    ("old_shape", envelope({"content": []}), "unclassified"),
    ("null_result", envelope(None), "unclassified"),
    ("both_fields", {**complete(), "error": PROTOCOL_ERROR["error"]}, "unclassified"),
]


class ReceiverTest(unittest.TestCase):
    receiver = staticmethod(classify)

    def test_categories(self):
        for name, response, expected in CASES:
            with self.subTest(name=name):
                self.assertEqual(self.receiver(response), expected)


if __name__ == "__main__":
    unittest.main()
python3 -m unittest response_test -v

Python 3.14.7で実行し、1テストメソッド内の12ケースがすべて通りました。Ran 1 testはケース数が1という意味ではありません。subTestで一つずつ期待分類を固定しています。

正常なisError省略と、文字列"false"を分けた点も確認します。文字列の真偽値をPythonのbool()で変換すると、空でない"false"は真になります。一方、数値0を== Falseで許すと型の違いを見落とします。小例では分岐フィールドの型違いを成功として通さないため、type(flag) is boolで限定しています。

old_shapeは古い仕様の応答が間違っているという判定ではありません。この小例の対象版に分類を限定するためのケースです。複数版を支えるなら、対応版ごとに正規化してから分類し、その版のテストを別に用意します。未知の型を正常と推測して補完する扱いは避けます。

同じテストで、悪い実装を止められるか

正常系だけを通しても、成功条件が広すぎる実装は残ります。そこで同じ12ケースと期待値を変えず、分類関数だけを二通りに置き換えました。

一つ目はresultの存在だけでnormal_completeを返すものです。二つ目はcompleteならisErrorを無視してnormal_completeを返すものです。両方とも、ほかの分岐は元の関数へ戻すようにして差分を限定しました。

分類関数 12ケース中の失敗 エラー 結果
掲載した関数 0 0 通過
resultがあれば正常完了 9 0 失敗
completeならisErrorを無視 5 0 失敗

これは作った二つの誤分類を検出できたという結果です。現実の全障害を検出できる、という結果ではありません。期待値を関数の出力から生成すると、この比較は成立しなくなるので、ケース側の分類名は固定してあります。

手元でも一つ目を確認するには、関数の冒頭へ次の二行を足して同じコマンドを実行します。9ケースの失敗を確認したら、追加した二行を戻します。

    if "result" in response:
        return "normal_complete"

分類したあとに、成功条件を置く

この小例には再試行ループを入れていません。tool_errorという分類だけでは、同じ操作を繰り返してよいか分からないからです。入力を直す必要があるエラー、追加入力待ち、状態確認が必要な操作を、共通のリトライへ流さないようにします。

実装へ組み込むときは、たとえば読み取り専用の件数取得なら「期待するキーがあり、件数の型・範囲が正しい」をnormal_completeの後で検査します。書き込みなら、それに加えて対象IDや変更後の状態を別の観測で確認します。ここで挙げた条件は導入時の例であり、本稿で実サービスへ書き込んで測定した結果ではありません。

まず自分の成功判定を一つ選び、isError: trueとinput_requiredを与えて成功へ進まないことを確認してください。分類器の通過、返された内容の確認、業務完了の観測を分けておけば、何を確認済みなのかをテスト名でも説明できます。

参照資料

仕様は2026年10月9日に確認しました。小例は標準ライブラリだけで動かし、ネットワーク通信・認証・業務操作は行っていません。

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?