手元で一度動いたコネクタを、そのまま夜間の無人ジョブへ繋いだ夜のことでした。翌朝ログを開くと、同じ記事更新の書き込みが 2 回走っていました。ツールの中身は正しく、権限も足りていて、対話で叩いたときは一度も再現しなかったのに、です。
原因はコネクタ側ではなく、その手前にありました。ネットワークが一瞬詰まってクライアントがリトライしたとき、書き込みツールに冪等性がなく、同じ操作が二重に適用されてしまったのです。対話中の私は目の前で結果を見て気づけますが、無人ジョブには見てくれる人がいません。7 月の更新で、作った MCP コネクタを Claude から直接ディレクトリへ申請できるようになりました。誰かに使ってもらう手前、あるいは自分の夜間ジョブに預ける手前で、「手元で動いた」を「契約として保証されている」に引き上げておきたい、と強く思った出来事でした。
その保証を機械で与える小さな契約テストハーネスを、これから TypeScript の MCP SDK で一段ずつ組み立てていきます。
手元で動いたのに、夜のジョブで静かに壊れた
対話でコネクタを叩く確認には、致命的な死角があります。人間が一度ずつ、成功したときの応答だけを見て「動いた」と判断してしまうことです。
無人運用で壊れるのは、たいてい成功パスの外側です。ツールがエラーを isError: true の構造化応答として返したのに、呼び出し側がそれを成功と読み違える。応答の形が前回と微妙に変わっていて、後段のパースが黙って空を掴む。リトライで書き込みが二重に走る。どれも、目の前で一度叩くだけの確認では絶対に表面化しません。
私自身、個人開発で複数の技術ブログの更新をモデルに任せていて、痛感しています。無人ジョブの信頼性は、コネクタが「一番良いときにどう動くか」ではなく、「揺れたときに約束を守るか」で決まります。だからこそ、申請や本番運用に出す手前で確かめるべきは機能の有無ではなく、ツールが守ると宣言した契約そのものです。
契約テストが確かめるべき4つの性質
契約テストは、単体テストとも E2E とも少し違います。コネクタが公開している「約束」を、外側のクライアントの立場から機械的に突き合わせる作業です。無人運用に出す前に、最低限この 4 つを固定します。
| 性質 | 確かめること | 破れたときに起きること |
| 自己記述 | 全ツールに説明文と有効な inputSchema があるか | モデルがツールを誤用・無視する |
| 応答契約 | 正常系の応答形状が宣言どおりか | 後段のパースが黙って空を掴む |
| 冪等性 | 書き込みを2回呼んでも副作用が1回か | リトライで二重発火する |
| レイテンシ | ツール別の応答時間が予算内か | タイムアウトで無人ジョブが落ちる |
順に、実際に動くコードへ落としていきます。以降は @modelcontextprotocol/sdk のクライアントを使い、テスト対象のコネクタへ外側から接続する構成を前提とします。
ツールの自己記述を検証する
最初の関門は、モデルがそのツールを正しく選べるだけの情報が揃っているか、です。説明文が空だったり、inputSchema が type: "object" を欠いていたりすると、モデルは引数を組み立てられず、ツールを黙って避けるか誤用します。
クライアントを接続し、listTools() で列挙して、一件ずつ機械的に検査します。
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
// テスト対象のコネクタを子プロセスとして起動して接続します
export async function connect(): Promise<Client> {
const transport = new StdioClientTransport({
command: "node",
args: ["dist/server.js"],
});
const client = new Client(
{ name: "contract-tester", version: "1.0.0" },
{ capabilities: {} },
);
await client.connect(transport);
return client;
}
type Issue = { tool: string; rule: string; detail: string };
export async function checkSelfDescription(client: Client): Promise<Issue[]> {
const { tools } = await client.listTools();
const issues: Issue[] = [];
for (const t of tools) {
// 説明文が無い・短すぎるツールは、モデルが用途を判断できません
if (!t.description || t.description.trim().length < 12) {
issues.push({ tool: t.name, rule: "description", detail: "説明文が無いか短すぎます" });
}
// inputSchema はオブジェクト型で properties を持つのが最低条件です
const schema = t.inputSchema as { type?: string; properties?: object } | undefined;
if (!schema || schema.type !== "object") {
issues.push({ tool: t.name, rule: "schema", detail: "inputSchema が object ではありません" });
} else if (!schema.properties) {
issues.push({ tool: t.name, rule: "schema", detail: "properties が定義されていません" });
}
}
return issues;
}
このチェックだけでも、リファクタで説明文を削ってしまった、スキーマを手書きして type を書き忘れた、といった事故を申請前に止められます。私はここで、引数を持たないはずのツールに空でない required が残っている、という取りこぼしを一度拾いました。
読み取りツールの応答形状を固定する
次に、正常系の応答が「宣言どおりの形」で返るかを固定します。ここで大事なのは、値の中身ではなく形を検査することです。中身まで固定すると、コネクタ側の些細な変更で毎回落ちるもろいテストになります。
読み取り専用ツールを、最小の有効な引数で一度だけ叩き、content 配列と isError の扱いを突き合わせます。
export async function checkReadContract(
client: Client,
tool: string,
args: Record<string, unknown>,
): Promise<Issue[]> {
const issues: Issue[] = [];
const res = await client.callTool({ name: tool, arguments: args });
// 正常系のはずが isError で返るなら、引数かサーバ実装の契約違反です
if (res.isError) {
issues.push({ tool, rule: "read-error", detail: "正常系の引数で isError が返りました" });
return issues;
}
// MCP のツール応答は content 配列を持つのが約束です
if (!Array.isArray(res.content) || res.content.length === 0) {
issues.push({ tool, rule: "read-shape", detail: "content が空、または配列ではありません" });
}
// 構造化出力を謳うツールは structuredContent の有無まで固定します
if (res.structuredContent !== undefined && typeof res.structuredContent !== "object") {
issues.push({ tool, rule: "read-structured", detail: "structuredContent がオブジェクトではありません" });
}
return issues;
}
なぜ中身でなく形なのか。無人ジョブが黙って壊れる典型は、応答が「エラーではないが期待した形でもない」ときだからです。isError を見ずに content[0].text だけを読むコードは、エラー応答を正常テキストとして飲み込みます。形の契約を先に固定しておけば、後段のパースが前提にしてよい最小限を保証できます。
応答本文の中身に依存した検証を足したくなったら、リモートツールの応答が揺れやすい点にも触れたリモート MCP ツール呼び出しのハング対策の考え方が参考になります。締め切りを持たない待ちは、無人運用では静かな停止に化けます。
書き込みツールの冪等性をセンチネルで確かめる
冒頭の二重発火は、ここで捕まえます。冪等性は「同じ操作を 2 回適用しても、副作用は 1 回分」という性質です。これを実際に副作用を起こさずに確かめるため、センチネル方式を使います。
考え方はこうです。ユニークな冪等キー(センチネル)を 1 つ生成し、それを付けて書き込みツールを 2 回呼びます。ツールが冪等になっているなら、2 回目は「すでに適用済み」を返し、新しいレコードは増えません。検証側は、呼び出しの前後で対象件数が 1 しか増えていないことを、読み取りツールで確かめます。
import { randomUUID } from "node:crypto";
export async function checkIdempotency(
client: Client,
writeTool: string,
countTool: string,
): Promise<Issue[]> {
const issues: Issue[] = [];
const key = `contract-${randomUUID()}`; // 一意なセンチネル
const before = await readCount(client, countTool);
// 同じ冪等キーで 2 回書き込みます
const first = await client.callTool({ name: writeTool, arguments: { idempotencyKey: key, payload: { sentinel: key } } });
const second = await client.callTool({ name: writeTool, arguments: { idempotencyKey: key, payload: { sentinel: key } } });
const after = await readCount(client, countTool);
if (first.isError) {
issues.push({ tool: writeTool, rule: "write-first", detail: "初回の書き込みが失敗しました" });
}
// 2 回目は成功応答(すでに適用済みを含む)で返るのが冪等な設計です
if (second.isError) {
issues.push({ tool: writeTool, rule: "idempotent-retry", detail: "2回目の呼び出しが isError になりました" });
}
// 件数が 2 増えていたら、二重発火しています
if (after - before > 1) {
issues.push({ tool: writeTool, rule: "idempotent-effect", detail: `件数が ${after - before} 増えました(期待は 1)` });
}
return issues;
}
async function readCount(client: Client, countTool: string): Promise<number> {
const res = await client.callTool({ name: countTool, arguments: {} });
const text = (res.content?.[0] as { text?: string })?.text ?? "0";
return Number.parseInt(text, 10) || 0;
}
コネクタ側が冪等キーを受け取る設計になっていない場合、このテストは容赦なく落ちます。それでよいのです。落ちたら、書き込みツールに冪等キーを導入するか、少なくともリトライでも二重発火を回避できる操作へ設計し直す合図です。この対処を早めに入れておくほど、後段の被害が小さくなります。サーバ側の冪等設計そのものについてはエージェント SDK の冪等ツールとアウトボックスに踏み込んだ議論があります。
私はこの型を、書き込みを含むコネクタには例外なく通すことにしています。対話では絶対に踏まない罠を、機械が代わりに踏んでくれるからです。
ツール別レイテンシからタイムアウト予算を決める
7 月の可観測性の強化で、コネクタのエラーやレイテンシが製品横断で見えるようになりました。ただ、無人ジョブのタイムアウトを何秒に置くかは、結局自分の環境で測るのが一番確かです。契約テストのついでに、ツール別の p50/p95 を採っておきます。
export async function measureLatency(
client: Client,
tool: string,
args: Record<string, unknown>,
runs = 20,
): Promise<{ p50: number; p95: number }> {
const samples: number[] = [];
for (let i = 0; i < runs; i++) {
const start = performance.now();
await client.callTool({ name: tool, arguments: args });
samples.push(performance.now() - start);
}
samples.sort((a, b) => a - b);
const at = (q: number) => Math.round(samples[Math.floor(q * (samples.length - 1))]);
return { p50: at(0.5), p95: at(0.95) };
}
手元のコネクタ 2 種で 20 回ずつ計ると、こうした差が出ました。ローカルの stdio サーバと、ネットワークを挟むリモートコネクタでは、p95 が一桁違います。
| ツール | 接続 | p50 | p95 | 推奨タイムアウト |
| 記事一覧の取得 | ローカル stdio | 118ms | 472ms | 3秒 |
| 件数の取得 | ローカル stdio | 96ms | 388ms | 3秒 |
| 外部データの照会 | リモート HTTP | 640ms | 1,840ms | 8秒 |
タイムアウトは p95 の 3〜4 倍を目安に置くことを推奨します。私はこの比率を、ローカルとリモートで別々に決めています。p95 のちょうど上に置くと、通常運転の揺れで無駄に落ちます。逆に無限に待たせると、詰まったリモート呼び出しがジョブ全体を止めます。この「測ってから決める」を一度やっておくと、リモート MCP の OAuth トークン更新のような別の失敗要因と切り分けやすくなります。遅いのか、認証で弾かれているのか、ログの前に数字で当たりが付きます。
CI と申請前チェックに一本化する
最後に、ここまでの検査を 1 本のランナーへまとめ、終了コードで合否を返します。人が結果を眺めて判断する余地を残さないことが肝心です。無人運用の思想と同じで、緑か赤かだけを残します。
async function main() {
const client = await connect();
const issues: Issue[] = [];
issues.push(...(await checkSelfDescription(client)));
issues.push(...(await checkReadContract(client, "list_articles", { limit: 1 })));
issues.push(...(await checkIdempotency(client, "upsert_article", "count_articles")));
const lat = await measureLatency(client, "list_articles", { limit: 1 });
console.log(`list_articles p50=${lat.p50}ms p95=${lat.p95}ms`);
await client.close();
if (issues.length > 0) {
for (const i of issues) console.error(`❌ [${i.tool}] ${i.rule}: ${i.detail}`);
process.exit(1); // 申請・デプロイをここで止めます
}
console.log("✅ contract passed");
}
main().catch((e) => { console.error(e); process.exit(1); });
これを GitHub Actions の申請前ジョブや、コネクタを更新するたびのフックに差し込みます。緑でなければ申請にも無人ジョブにも出さない、という一線を機械に守らせるわけです。終了コードで止める設計の落とし穴についてはゲートと push を同じ呼び出しに混ぜる罠でも触れましたが、検証と公開は必ず別の段に分けるのが安全です。
次の一歩
まず、手元のコネクタで checkSelfDescription だけを走らせてみてください。ツールを列挙して説明文と inputSchema を突き合わせるだけの、数十行のコードです。それだけでも、対話では気づけない欠落がひとつふたつ見つかるはずです。そこから応答契約、冪等性、レイテンシへと一段ずつ広げていけば、「手元で動いた」は「揺れても約束を守る」に変わっていきます。
私自身、まだ全ツールの契約を書ききれてはいませんが、書き込みを含むコネクタから順に固めています。誰かに渡す前の一手間が、夜のログを静かに保ってくれています。お読みいただきありがとうございました。