Agent SDKカスタムツールの作り方 — インプロセスMCPサーバーで機能拡張
Agent SDKのインプロセスMCPサーバーで独自ツールを定義する手順。天気ツールの実装例からエラーハンドリング、structuredContentでの構造化データ返却まで扱います。
Agent SDKカスタムツールとは — インプロセスMCPサーバーで独自機能を足す仕組み
Agent SDKのカスタムツールとは、SDKが内蔵するインプロセスMCPサーバー(自分のアプリケーションと同じプロセス内で動くMCPサーバー)を使って、Claudeが呼び出せる独自の関数を追加する機能です。外部サービスのAPI呼び出しやデータベースへのクエリ、社内システム固有のロジックなど、組み込みツール(Read / Write / Bashなど)ではカバーできない処理をClaudeに渡せます。
ツールは名前・説明・入力スキーマ・ハンドラの4要素で定義します。TypeScriptなら tool() 関数、Pythonなら @tool デコレータを使い、どちらも createSdkMcpServer() / create_sdk_mcp_server() でサーバーとしてまとめてから query() の mcpServers オプションに渡します。別プロセスを立ち上げる必要はなく、関数を書いてサーバーオブジェクトに登録するだけで動き始めるのが最大の特徴です。query() そのものの基本的な使い方はAgent SDKクイックスタートで解説しています。
外部の標準MCPサーバーに接続する話は別の切り口になります。すでに動いているMCPサーバー(GitHubやSlackなど)を利用したい場合はAgent SDKからMCPサーバーに接続する手順を、npmやMCPレジストリで配布できる独立したサーバーを一から作りたい場合はMCPサーバー自作ガイドを参照してください。本記事が扱うのは、あくまで自分のアプリケーションの中だけで完結するツール追加です。
手順1 — 天気ツールを定義する
ハンドラは非同期関数で、content 配列を含むオブジェクトを返します。次の例は緯度・経度を受け取り、Open-Meteo APIから気温を取得する get_temperature ツールです。
import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
const getTemperature = tool(
"get_temperature",
"Get the current temperature at a location",
{
latitude: z.number().describe("Latitude coordinate"),
longitude: z.number().describe("Longitude coordinate")
},
async (args) => {
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${args.latitude}&longitude=${args.longitude}¤t=temperature_2m`
);
const data: any = await response.json();
return {
content: [{ type: "text", text: `Temperature: ${data.current.temperature_2m}°C` }]
};
}
);
const weatherServer = createSdkMcpServer({
name: "weather",
version: "1.0.0",
tools: [getTemperature]
});Pythonでは辞書スキーマを使います。{"latitude": float} のような型指定は、SDK内部でJSON Schemaへ変換されます。列挙値や範囲、オプション引数が必要な場合だけ、フルのJSON Schema辞書を直接渡します(enumはPythonの簡易辞書スキーマでは表現できないため必須)。
from claude_agent_sdk import tool, create_sdk_mcp_server
import httpx
@tool(
"get_temperature",
"Get the current temperature at a location",
{"latitude": float, "longitude": float},
)
async def get_temperature(args):
async with httpx.AsyncClient() as client:
response = await client.get(
"https://api.open-meteo.com/v1/forecast",
params={"latitude": args["latitude"], "longitude": args["longitude"], "current": "temperature_2m"},
)
data = response.json()
return {"content": [{"type": "text", "text": f"Temperature: {data['current']['temperature_2m']}°C"}]}
weather_server = create_sdk_mcp_server(name="weather", version="1.0.0", tools=[get_temperature])手順2 — サーバーをqueryに登録して呼び出す
ツール名は mcp__{server_name}__{tool_name} という形式でClaudeに見えます。上の例なら mcp__weather__get_temperature です。この名前を allowedTools に列挙すると、実行のたびに許可を求められずに動きます。
import { query } from "@anthropic-ai/claude-agent-sdk";
for await (const message of query({
prompt: "What's the temperature in San Francisco?",
options: {
mcpServers: { weather: weatherServer },
allowedTools: ["mcp__weather__get_temperature"]
}
})) {
if (message.type === "result" && message.subtype === "success") {
console.log(message.result);
}
}npx tsx weather.tsサーバーに複数のツールを持たせるときは、tools 配列にまとめて渡し、allowedTools は個別列挙かワイルドカード mcp__weather__* のどちらかで許可します。ワイルドカードはサーバー名部分をグロブにできない点に注意してください。mcp__* のような書き方は起動時の警告とともに無視され、何も自動承認されません。サーバー名まで明示した mcp__weather__* の形でのみ機能します。
手順3 — エラーハンドリングを実装する
ハンドラが例外を投げても、それだけでエージェントループは止まりません。SDKのインプロセスMCPサーバーが例外を捕まえ、エラー結果としてClaudeに渡すからです。ただし、どう報告するかでClaudeが読む内容が変わります。
| 起きたこと | Claudeが受け取る内容 |
|---|---|
| ハンドラが例外を投げっぱなしにする | Claudeが受け取る内容生の例外メッセージがそのまま渡る |
ハンドラが自前で捕まえて isError: true を返す | Claudeが受け取る内容開発者が組み立てたメッセージが渡る(失敗したリクエストの内容や次に試す手立てを添えられる) |
自前でエラーを組み立てておくと、Claudeは「別のエンドポイントを試す」「引数を変えて再実行する」といった判断がしやすくなります。生の例外文字列だけでは情報が乏しく、Claudeが的外れな再試行を繰り返す原因になりがちです。
async (args) => {
try {
const response = await fetch(args.endpoint);
if (!response.ok) {
return {
content: [{ type: "text", text: `API error: ${response.status} ${response.statusText}` }],
isError: true
};
}
return { content: [{ type: "text", text: JSON.stringify(await response.json()) }] };
} catch (error) {
return {
content: [{ type: "text", text: `Failed to fetch data: ${String(error)}` }],
isError: true
};
}
}構造化データを返す — structuredContentとPythonでの制限
content 配列とは別に、structuredContent というJSONフィールドを結果に付けられます。グラフの画像とその元データを同時に返すような場面で、Claudeが数値を文字列から読み取り直す必要がなくなります。
return {
content: [{ type: "image", data: chartPngBase64, mimeType: "image/png" }],
structuredContent: { series: "temperature_2m", unit: "celsius", points: [16.7, 17.2, 18.0] }
};ツールの振る舞いを申告する(アノテーション)
readOnlyHint 以外にも、ツールの性質を伝えるアノテーションが3つあります。いずれも強制力のないヒントで、実際の挙動を保証するものではありません。
| フィールド | 既定値 | 意味 |
|---|---|---|
destructiveHint | 既定値true | 意味破壊的な更新を行う可能性がある |
idempotentHint | 既定値false | 意味同じ引数で繰り返し呼んでも追加の効果がない |
openWorldHint | 既定値true | 意味プロセス外のシステムに到達する(Web検索など) |
これらは情報提供のためのヒントで、Claudeやクライアント側がセキュリティ判断の根拠にしてはいけないとMCP仕様でも明記されています。readOnlyHint: true を付けたツールがディスクに書き込んでいても実行時エラーにはならないため、ハンドラの実際の挙動とアノテーションの整合は書き手が責任を持つ必要があります。
画像やリソースを結果として返す
content 配列には text 以外に image / audio / resource / resource_link ブロックも混在させられます。resource ブロックは、生成したファイルや外部システムのレコードのように、あとで名前を付けて参照させたい内容に向いています。URIはClaudeが参照するためのラベルで、SDKがそのパスを実際に読みに行くわけではありません。
return {
content: [
{
type: "resource",
resource: { uri: "file:///tmp/report.md", mimeType: "text/markdown", text: "# Report\n..." }
}
]
};音声ブロックの扱いは言語によって差があります。TypeScriptはファイルに保存してパスをテキストブロックとしてClaudeに返しますが、Pythonは音声ブロックをそのまま結果から取り除き、警告をログに出すだけです。TypeScriptでも音声を多用する構成を作る場合は、この非対称を踏まえて動作確認しておく必要があります。同様にPython SDKは、バイナリデータを持つ resource ブロック(resource.blob)も結果から取り除いて警告を出すため、バイナリのリソースを返したい場合はTypeScript側で実装するか、テキストベースのresourceに絞る必要があります。
ツールへのアクセス制御 — 見えるかどうかと、承認がいるかどうか
tools オプションと許可リストは、性質が異なる2つの層に効きます。availability(Claudeの視界に入るか)とpermission(呼び出しに承認がいるか)を混同しないことが権限設計の出発点です。
| 設定 | 層 | 効果 |
|---|---|---|
tools: ["Read", "Grep"] | 層availability | 効果列挙した組み込みツールだけが見える。MCPツールには影響しない |
tools: [] | 層availability | 効果組み込みツールを全部外し、MCPツールだけを見せる |
allowedTools | 層permission | 効果列挙したツールは承認プロンプトなしで実行される |
disallowedTools(裸のツール名) | 層両方 | 効果ツール自体を視界から外す |
disallowedTools("Bash(rm *)" のようなスコープ付き) | 層permission | 効果視界には残るが、一致する呼び出しだけ拒否する |
組み込みツールを完全に無効化したいときは tools から外すか裸の名前で disallowedTools に入れます。スコープ付きのdisallowedルールはツールを視界に残したまま特定の呼び出しだけを拒否するので、Claudeが無駄なターンを使って弾かれる呼び出しを試すことがあります。
ツールが増えたときの注意 — Tool Searchとの関係
Tool Searchは既定でオンになっており、SDK MCPサーバーのツールも自動的に遅延ロードの対象になります。Claudeにはツール名の一覧だけが見え、必要になったタイミングで完全なスキーマを取りに行きます。常に使う少数のツールをコンテキストに残しておきたいときは、tool() の第5引数(TypeScript)で alwaysLoad: true を渡すか、createSdkMcpServer() の alwaysLoad オプションでサーバー単位にまとめて指定します。数百〜数千のツールを扱う運用の詳細はTool Searchの使い方にまとめています。
早見表 — 3つの選択肢のどれを使うか
「独自の処理をClaudeに渡したい」というゴールは同じでも、実装先は3通りあります。
| 選択肢 | 実行場所 | 配布・共有 | 向く場面 |
|---|---|---|---|
| SDKカスタムツール(本記事) | 実行場所自分のアプリと同じプロセス | 配布・共有不可(コードに埋め込み) | 向く場面自社アプリ専用の軽い処理、structuredContentが不要な場合 |
| 外部MCPサーバーへの接続 | 実行場所別プロセス・別サーバー | 配布・共有既存サーバーをそのまま利用 | 向く場面GitHub / Slack / DBなど既製のMCPサーバーがある場合 |
| MCPサーバー自作(標準SDK) | 実行場所別プロセス | 配布・共有npmやMCPレジストリで配布可能 | 向く場面複数プロジェクトで再利用したい、PythonでstructuredContentが必要 |
よくあるつまずき
allowedToolsへの登録漏れ: ツール自体は動いていても、mcp__weather__get_temperatureをallowedToolsに入れ忘れると、承認判定は毎回の確認フロー(canUseToolコールバックが設定されていればそちら)に回されます。動作は権限モードによって分かれる点に注意してください — 通常モードでは承認プロンプトが出ますが、プロンプトを出さずに拒否する運用にしている場合は、登録漏れがそのままサイレントな失敗につながります。- Pythonのenumが書けない:
{"unit": str}のような簡易スキーマにはenum制約を書く手段がありません。選択肢を絞りたい場合はフルJSON Schema辞書("enum": [...])に切り替える必要があります。 - 画像をURLのまま返そうとする:
imageブロックにURLフィールドはありません。ハンドラ側で画像を取得し、バイト列をbase64エンコードしてからdataに入れます。 - 並列実行されないと思い込む: 副作用のないツールに
readOnlyHint: trueを付けていないと、Claudeは安全のため直列に呼び出します。読み取り専用の処理には積極的に付けておくと体感速度が変わります。
よくある質問
SDKカスタムツールを別プロセスや別チームから呼び出せますか
いいえ、インプロセスMCPサーバーは自分のアプリケーションのプロセス内でのみ動きます。他のアプリからも呼び出したい場合は、標準のMCP SDKで独立したサーバーを作り、MCPサーバー自作ガイドの手順でnpmやMCPレジストリに配布する形になります。
1つのサーバーに何個までツールを登録できますか
技術的な上限はサーバー側のコード次第で決まりますが、Tool Search全体の上限は1カタログあたり10,000ツールです。数十を超えたあたりから精度低下が起きやすいため、増えてきたらTool Searchの設定を見直すのが実務的な目安になります。
アノテーション(annotations)を付けないと動作に問題がありますか
アノテーションはメタデータであり、強制力はありません。readOnlyHint: true を付け忘れても呼び出し自体は成功しますが、並列実行の対象から外れるため、複数のAPI呼び出しを伴うタスクで体感速度が落ちることがあります。
ハンドラの中で標準MCPサーバーを呼び出せますか
インプロセスサーバーの中から別のMCPサーバー(標準プロセスやHTTPサーバー)へHTTPリクエストを送ること自体は可能ですが、それはSDKの機構ではなくただのアプリケーションコードです。複数の外部サーバーを組み合わせたい場合は、Agent SDKからMCPサーバーに接続する手順で mcpServers に並べて登録するほうが素直です。
まとめ
Agent SDKのカスタムツールは、名前・説明・入力スキーマ・ハンドラの4要素を書き、インプロセスMCPサーバーにまとめて query() に渡すだけで動きます。エラーは isError で意味のあるメッセージに組み立て、structuredContentが必要ならPythonの制限を踏まえて標準MCPサーバーとの使い分けを判断してください。ツールが数十を超えてきたら、Tool Searchの設定を先に確認しておくと、あとからのつまずきを減らせます。