
こんにちは。よっしーです(^^)
背景
この連載では、Claude Codeの公式ドキュメントを1ページずつ読み解いていきます。公式ドキュメントは情報が網羅されている分、「結局どの機能を、どんな場面で使えばいいのか」は自分で考える必要があり、読むのに意外と時間がかかります。そこで、私が実務で使うために読み込んだ内容を「使う場面→実例コード」の順に整理して残していくことにしました。専門家の解説というより、一次情報を読んだ記録です。推測や動作を確認していない部分には、その都度そう書きます。
公式ドキュメント:https://code.claude.com/docs/ja/agent-sdk/custom-tools
1. 一言でいうと
Agent SDK では、自分で書いた関数を「ツール」として Claude に渡せます。Claude は会話の中で必要だと判断したときにその関数を呼び、結果を読んで作業を続けます。社内のデータベース、外部の API、業務固有の計算などを、Claude が使える道具にするための仕組みです。
作ったツールは、アプリの中で動く MCP サーバー(インプロセス MCP サーバー)にまとめて渡します。別のプロセスを立ち上げる必要はありません。MCP は Claude Code 本体でも外部の道具をつなぐ標準の仕組みで(第8回)、カスタムツールはその MCP サーバーを自分のプログラムの中に持つ形になります。
2. どういう場面で役立つか
シーン1:社内システムのデータを Claude に調べさせる
在庫や顧客情報のような社内データは、Claude の組み込みツール(ファイルの読み書きやシェルコマンド)では直接扱えません。「商品コードから在庫数を返す」関数をカスタムツールにすれば、Claude は「この商品はあと何個ある?」という質問に、その関数を呼んで答えられます。
シーン2:計算や変換を Claude の推測に任せない
単位の変換や料金の計算のように答えが1つに決まる処理は、Claude が頭の中で計算するより、正しいとわかっている関数に任せたほうが確実です。原文の単位変換の例では、Claude が依頼文から「長さ」「キロメートル→マイル」を読み取り、計算自体は関数が行います。
シーン3:組み込みツールを外し、自作ツールだけを使うエージェントにする
tools: [] を指定すると、ファイル操作やシェルなどの組み込みツールがすべて外れ、Claude は MCP ツールだけを使うようになります。顧客対応のボットのように、決められた操作以外をさせたくないエージェントを作るときに使えます。
不要・向かないケース
- 既存の MCP サーバー(ファイルシステム、GitHub、Slack など)を使いたいだけ:自作する必要はありません。外部の MCP サーバーを接続する方法は別ページにあります。
- ユーザーに選択肢を示して答えてもらいたいだけ:第81回の
AskUserQuestionで足ります。複数選択を超えるフォームや、既存の承認システムとの連携が必要になったときが、カスタムツールの出番です。 - Python で機械が読める JSON(
structuredContent)を返したい:Python のインプロセスサーバーでは返せません。独立した MCP サーバーを立てる必要があります(後述)。
3. コードと仕組みの解説
原文の fenced コードブロックは19本です。どれも内容が違い、同じ例の再掲がないので、全数を引用します。Python と TypeScript の対訳は両方載せます。コードは原文から機械的に抜き出したもので、入れ子に由来する先頭インデントを外した以外は変えていません。
このページにはバージョン要件の記載がありません。
3-0. やりたいこと別の早見表
原文の冒頭にある早見表です。この記事の各節への案内も兼ねます。
| やりたいこと | 方法 | 記事の節 |
|---|---|---|
| ツールを定義する | Python は @tool、TypeScript は tool() で、名前・説明・スキーマ・ハンドラーを指定する | 3-1 |
| Claude にツールを登録する | create_sdk_mcp_server/createSdkMcpServer で包み、query() の mcpServers に渡す | 3-2 |
| ツールを事前承認する | 許可リスト(allowedTools)に加える | 3-2・3-5 |
| 組み込みツールをコンテキストから外す | 必要な組み込みツールだけを並べた tools 配列を渡す | 3-5 |
| ツールを並列で呼べるようにする | 副作用のないツールに readOnlyHint: true を付ける | 3-4 |
| Claude が読むエラー文を自分で決める | 生の例外を出す代わりに isError: true を返す | 3-6 |
| 画像やファイルを返す | content 配列で image や resource ブロックを使う | 3-7 |
| 機械が読める JSON を返す | 結果に structuredContent を入れる | 3-8 |
| ツールが多くなったとき | ツール検索で必要なときだけ読み込む | 3-3 |
3-1. ツールを定義する
ツールは4つの部品でできています。TypeScript の tool() ヘルパーか、Python の @tool デコレーターに引数として渡します。
| 部品 | 内容 |
|---|---|
| 名前 | Claude がツールを呼ぶときに使う一意の識別子 |
| 説明 | ツールが何をするか。Claude はこれを読んで、いつ呼ぶかを決める |
| 入力スキーマ | Claude が渡す引数の定義。書き方が言語で違う(下記) |
| ハンドラー | Claude がツールを呼んだときに走る非同期関数。検証済みの引数を受け取り、結果のオブジェクトを返す |
入力スキーマの書き方は言語で大きく違います。
- TypeScript:常に Zod のスキーマで書きます。ハンドラーの
argsには、スキーマから自動的に型が付きます。 - Python:
{"latitude": float}のような「名前→型」の dict で書き、SDK が JSON Schema に変換します。列挙(決まった値のどれか)、範囲、省略可能なフィールド、入れ子のオブジェクトが必要な場合は、完全な JSON Schema の dict を直接渡せます。
ハンドラーが返すオブジェクトのフィールドは3つです。
| フィールド | 必須 | 内容 |
|---|---|---|
content | 必須 | 結果ブロックの配列。各ブロックの type は "text"・"image"・"audio"・"resource"・"resource_link" のいずれか |
structuredContent | 任意 | 結果を機械が読めるデータとして持つ JSON オブジェクト。content と一緒に返す |
isError | 任意 | true にするとツールの失敗を知らせ、Claude が対応できるようにする |
定義したツールは、createSdkMcpServer(TypeScript)か create_sdk_mcp_server(Python)でサーバーに包みます。このサーバーはアプリの中で動き、別のプロセスにはなりません。
天気ツールの例
指定した緯度・経度の現在の気温を返す get_temperature ツールを定義し、weather という名前のサーバーに包む例です。ここではツールの準備だけを行い、実行は次の 3-2 で行います。
from typing import Any
import httpx
from claude_agent_sdk import tool, create_sdk_mcp_server
# Define a tool: name, description, input schema, handler
@tool(
"get_temperature",
"Get the current temperature at a location",
{"latitude": float, "longitude": float},
)
async def get_temperature(args: dict[str, Any]) -> dict[str, Any]:
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",
"temperature_unit": "fahrenheit",
},
)
data = response.json()
# Return a content array - Claude sees this as the tool result
return {
"content": [
{
"type": "text",
"text": f"Temperature: {data['current']['temperature_2m']}°F",
}
]
}
# Wrap the tool in an in-process MCP server
weather_server = create_sdk_mcp_server(
name="weather",
version="1.0.0",
tools=[get_temperature],
)
import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
// Define a tool: name, description, input schema, handler
const getTemperature = tool(
"get_temperature",
"Get the current temperature at a location",
{
latitude: z.number().describe("Latitude coordinate"), // .describe() adds a field description Claude sees
longitude: z.number().describe("Longitude coordinate")
},
async (args) => {
// args is typed from the schema: { latitude: number; longitude: number }
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${args.latitude}&longitude=${args.longitude}¤t=temperature_2m&temperature_unit=fahrenheit`
);
const data: any = await response.json();
// Return a content array - Claude sees this as the tool result
return {
content: [{ type: "text", text: `Temperature: ${data.current.temperature_2m}°F` }]
};
}
);
// Wrap the tool in an in-process MCP server
const weatherServer = createSdkMcpServer({
name: "weather",
version: "1.0.0",
tools: [getTemperature]
});
言語の違いを見ておきます。
- TypeScript は Zod の
.describe()でフィールドごとの説明を付けています。コメントにあるとおり、この説明も Claude が読みます。Python の単純な dict では説明を付けられないので、フィールドの説明が必要なら完全な JSON Schema を使います(3-9 の例)。 - 天気の取得先はどちらも Open-Meteo の API で、気温は華氏(
fahrenheit)で取っています。
引数の完全な仕様(JSON Schema の入力形式、戻り値の構造)は、TypeScript の tool()、Python の @tool のリファレンスに回されています。
引数を省略可能にする方法も言語で違います。
- TypeScript:Zod のフィールドに
.default()を付けます。 - Python:単純な dict スキーマは全てのキーを必須として扱うので、その引数はスキーマに入れません。代わりに説明文でその引数に触れ、ハンドラーの中で
args.get()で読みます。
どちらのやり方も 3-3 の get_precipitation_chance ツールに出てきます。
3-2. ツールを呼び出す
作ったサーバーを、query の mcpServers オプションに渡します。mcpServers に付けたキーが、ツールの正式名 mcp__{server_name}__{tool_name} の {server_name} の部分になります。 この正式名を allowedTools に書くと、ツールは権限の確認なしに実行されます。
次の例は、3-1 の weatherServer を使って、ある場所の天気を Claude に尋ねます。
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
async def main():
options = ClaudeAgentOptions(
mcp_servers={"weather": weather_server},
allowed_tools=["mcp__weather__get_temperature"],
)
async for message in query(
prompt="What's the temperature in San Francisco?",
options=options,
):
# ResultMessage is the final message after all tool calls complete
if isinstance(message, ResultMessage) and message.subtype == "success":
print(message.result)
asyncio.run(main())
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"]
}
})) {
// "result" is the final message after all tool calls complete
if (message.type === "result" && message.subtype === "success") {
console.log(message.result);
}
}
このコードと 3-1 のツール・サーバーの定義を1つのファイルにまとめ、Python なら python weather.py、TypeScript なら npx tsx weather.ts で実行します。Claude が get_temperature を呼び、スクリプトはサンフランシスコの現在の気温を含む1行の答えを表示します。
キーが weather、ツール名が get_temperature なので、正式名は mcp__weather__get_temperature です。第82回のフックのマッチャーで mcp__<server>__<action> と書いたのは、この名前のことです。自作ツールにも、フックや権限ルールはそのまま効きます。
allowedTools は第79回・第80回で見たとおり「事前承認リスト」です。書かなくてもツールは使えますが、使うたびに権限の評価を通り、最後に canUseTool へ回ります(第81回)。書けば確認なしで走ります。
3-3. ツールを増やす
1つのサーバーには、tools 配列に並べただけのツールを持たせられます。サーバーに複数のツールがある場合、allowedTools には各ツールを1つずつ書くか、ワイルドカード mcp__weather__* でそのサーバーの全ツールをまとめて書けます。第80回で見たとおり、許可ルールのワイルドカードは mcp__<server>__ の後ろにしか書けません。この書き方はその規則どおりです。
次の例は2つ目のツール get_precipitation_chance(1時間ごとの降水確率)を定義し、3-1 の weatherServer を、両方のツールを持つものに置き換えます。
# Define a second tool for the same server
@tool(
"get_precipitation_chance",
"Get the hourly precipitation probability for a location. "
"Optionally pass 'hours' (1-24) to control how many hours to return.",
{"latitude": float, "longitude": float},
)
async def get_precipitation_chance(args: dict[str, Any]) -> dict[str, Any]:
# 'hours' isn't in the schema - read it with .get() to make it optional
hours = args.get("hours", 12)
async with httpx.AsyncClient() as client:
response = await client.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": args["latitude"],
"longitude": args["longitude"],
"hourly": "precipitation_probability",
"forecast_days": 1,
},
)
data = response.json()
chances = data["hourly"]["precipitation_probability"][:hours]
return {
"content": [
{
"type": "text",
"text": f"Next {hours} hours: {'%, '.join(map(str, chances))}%",
}
]
}
# Rebuild the server with both tools in the array
weather_server = create_sdk_mcp_server(
name="weather",
version="1.0.0",
tools=[get_temperature, get_precipitation_chance],
)
// Define a second tool for the same server
const getPrecipitationChance = tool(
"get_precipitation_chance",
"Get the hourly precipitation probability for a location",
{
latitude: z.number(),
longitude: z.number(),
hours: z
.number()
.int()
.min(1)
.max(24)
.default(12) // .default() makes the parameter optional
.describe("How many hours of forecast to return")
},
async (args) => {
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${args.latitude}&longitude=${args.longitude}&hourly=precipitation_probability&forecast_days=1`
);
const data: any = await response.json();
const chances = data.hourly.precipitation_probability.slice(0, args.hours);
return {
content: [{ type: "text", text: `Next ${args.hours} hours: ${chances.join("%, ")}%` }]
};
}
);
// Rebuild the server with both tools in the array
const weatherServer = createSdkMcpServer({
name: "weather",
version: "1.0.0",
tools: [getTemperature, getPrecipitationChance]
});
ここに 3-1 で触れた「省略可能な引数」の2通りの書き方が出ています。
- TypeScript は
hoursをスキーマに入れ、.int().min(1).max(24).default(12)で「1〜24の整数、省略時は12」と定義しています。範囲外の値は SDK の検証で弾かれます。 - Python は
hoursをスキーマに入れず、説明文に「’hours’(1〜24)を任意で渡せる」と書き、ハンドラーでargs.get("hours", 12)と読んでいます。
Python 側には範囲の検証がありません。Claude が説明文を読んで1〜24を守ることを期待しているだけで、範囲外の値が来てもハンドラーはそのまま使います。範囲が大事な引数なら、ハンドラーの中で自分で確認するか、完全な JSON Schema で minimum・maximum を書くほうが確実です(筆者の指摘)。
ツール検索との関係。 ツール検索は既定で有効で、SDK の MCP ツールは「遅延」扱いになります。Claude は各ツールの名前だけを短い一覧で見ておき、必要になったときに完全なスキーマを読み込みます。ツール検索を無効にすると、この配列のツールは全て、毎ターンコンテキストウィンドウの容量を使います。第70回で見た ENABLE_TOOL_SEARCH の話が、自作ツールにも当てはまります。
特定のツールの完全なスキーマを最初から読み込んでおきたい場合、TypeScript では alwaysLoad: true を渡します。渡し先は tool() の extras 引数か、createSdkMcpServer() のオプションです。
3-4. ツールに注釈を付ける
ツール注釈は、ツールの振る舞いを説明する任意の情報(メタデータ)です。TypeScript では tool() の5番目の引数で、Python では @tool の annotations キーワード引数で渡します。値はすべて真偽値です。
| フィールド | 既定値 | 意味 |
|---|---|---|
readOnlyHint | false | ツールは環境を変えない。他の読み取り専用ツールと並列で呼べるかどうかを左右する |
destructiveHint | true | ツールは破壊的な更新をしうる。情報として示すだけ |
idempotentHint | false | 同じ引数で何度呼んでも追加の影響がない。情報として示すだけ |
openWorldHint | true | ツールはプロセスの外のシステムに接続する。情報として示すだけ |
実際に動作を変えるのは readOnlyHint だけで、残りの3つは情報として示すだけです。第77回で見たとおり、カスタムツールは既定では直列(1つずつ)で実行されます。readOnlyHint: true を付けると、他の読み取り専用ツールとまとめて並列に呼べるようになります。
注釈は宣言であって強制ではありません。readOnlyHint: true と書いたツールでも、ハンドラーがディスクに書き込めば書き込まれます。注釈はハンドラーの実際の動きと合わせておく必要があります。並列に呼ばれる前提で「読み取り専用」と書いたツールが実は状態を変えていると、実行順が保証されないぶん不具合の原因になります(筆者の指摘)。
次の例は、3-1 の get_temperature に readOnlyHint を付けます。
from claude_agent_sdk import tool, ToolAnnotations
@tool(
"get_temperature",
"Get the current temperature at a location",
{"latitude": float, "longitude": float},
annotations=ToolAnnotations(
readOnlyHint=True
), # Lets Claude batch this with other read-only calls
)
async def get_temperature(args):
return {"content": [{"type": "text", "text": "..."}]}
import { tool } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
tool(
"get_temperature",
"Get the current temperature at a location",
{ latitude: z.number(), longitude: z.number() },
async (args) => ({ content: [{ type: "text", text: `...` }] }),
{ annotations: { readOnlyHint: true } } // Lets Claude batch this with other read-only calls
);
Python は ToolAnnotations(readOnlyHint=True) を annotations= で、TypeScript は { annotations: { readOnlyHint: true } } を5番目の引数で渡しています。ToolAnnotations の詳細は各言語のリファレンスにあります。
3-5. ツールの使用範囲を決める
tools オプションと、許可・禁止のリストは、2つの層に効きます。
- 使える状態(可用性):ツールが Claude のコンテキストに見えているかどうか
- 権限:Claude がツールを使おうとしたあと、その呼び出しが承認されるかどうか
tools と、ツール名だけを書いた disallowedTools は「使える状態」を変えます。allowedTools と、スコープ付きの disallowedTools ルールは「権限」を変えます。
| オプション | 層 | 効果 |
|---|---|---|
tools: ["Read", "Grep"] | 使える状態 | 並べた組み込みツールだけがコンテキストに入る。並べていない組み込みツールは外れる。MCP ツールには影響しない |
tools: [] | 使える状態 | 組み込みツールが全て外れる。Claude は MCP ツールだけを使える |
許可リスト(allowedTools) | 権限 | 並べたツールは確認なしで実行される。並べていないツールも使えるまま残り、呼び出しは権限の評価を通る |
禁止リスト(disallowedTools) | 両方 | "Bash" のようなツール名だけの指定は、tools から外すのと同じくツールをコンテキストから消す。"Bash(rm *)" のようなスコープ付きルールはツールを残し、一致する呼び出しだけを拒否する |
表の1行目にあるとおり、tools で絞っても自作の MCP ツールは消えません。tools: [] にすれば「自作ツールしか使えないエージェント」になります。
組み込みツールを完全に外すには、tools に書かないか、disallowedTools(Python は disallowed_tools)にツール名だけを書きます。どちらもツールをコンテキストから除くので、Claude はそのツールを試そうとしません。**スコープ付きの disallowedTools ルールは、一致する呼び出しを止めますがツールは見えたままなので、Claude が試しては止められる、というターンの無駄が起きることがあります。**使わせたくないツールは、ルールで止めるより見えなくするほうが効率的です。
また、タスク追跡ツールのどれかを allowedTools に名前で書くと、セッションがタスク追跡に参加します(第80回と同じ記述)。評価順序の詳細は第80回を参照してください。
3-6. エラーを処理する
ハンドラーのエラーでエージェントのループは止まりません。 インプロセスの MCP サーバーは、捕まえられなかった例外を捕まえてエラー結果として返します。つまり、エラーの報告の仕方で決まるのは「クエリが失敗するかどうか」ではなく、「Claude が何を読むか」です。
| 起きること | 結果 |
|---|---|
| ハンドラーが例外を捕まえずに投げる | MCP サーバーがエラー結果に変換し、生の例外メッセージを入れる。Claude はそのメッセージを見て、ループは続く |
ハンドラーが例外を捕まえて isError: true(TS)/"is_error": True(Python)を返す | Claude は自分で書いたメッセージを読む。生の例外にはない情報(どのリクエストが失敗したか、代わりに何を試せばよいか)を足せる |
どちらの場合も、Claude は再試行したり、別のツールを試したり、失敗を説明したりできます。生の例外メッセージだけでは Claude が対応しきれない場合に、自分で例外を捕まえます。
フィールド名が言語で違う点に注意してください。 TypeScript は isError、Python は is_error です(早見表は isError とだけ書いています)。
次の例は、ハンドラーの中で2種類の失敗を捕まえ、Claude が読むエラー文を組み立てます。
- HTTP ステータスが200以外:レスポンスを見て判定し、エラー結果として返す
- ネットワークエラーや不正な JSON:周りの
try/except(Python)/try/catch(TypeScript)で捕まえ、エラー結果として返す
import json
import httpx
from typing import Any
from claude_agent_sdk import tool
@tool(
"fetch_data",
"Fetch data from an API",
{"endpoint": str}, # Simple schema
)
async def fetch_data(args: dict[str, Any]) -> dict[str, Any]:
try:
async with httpx.AsyncClient() as client:
response = await client.get(args["endpoint"])
if response.status_code != 200:
# Return the failure as a tool result so Claude can react to it.
# is_error marks this as a failed call rather than odd-looking data.
return {
"content": [
{
"type": "text",
"text": f"API error: {response.status_code} {response.reason_phrase}",
}
],
"is_error": True,
}
data = response.json()
return {"content": [{"type": "text", "text": json.dumps(data, indent=2)}]}
except Exception as e:
# Composes the message Claude reads. An uncaught exception would
# reach Claude as the raw str(e) with no context.
return {
"content": [{"type": "text", "text": f"Failed to fetch data: {str(e)}"}],
"is_error": True,
}
import { tool } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
tool(
"fetch_data",
"Fetch data from an API",
{
endpoint: z.string().url().describe("API endpoint URL")
},
async (args) => {
try {
const response = await fetch(args.endpoint);
if (!response.ok) {
// Return the failure as a tool result so Claude can react to it.
// isError marks this as a failed call rather than odd-looking data.
return {
content: [
{
type: "text",
text: `API error: ${response.status} ${response.statusText}`
}
],
isError: true
};
}
const data = await response.json();
return {
content: [
{
type: "text",
text: JSON.stringify(data, null, 2)
}
]
};
} catch (error) {
// Composes the message Claude reads. An uncaught throw would
// reach Claude as the raw error message with no context.
return {
content: [
{
type: "text",
text: `Failed to fetch data: ${error instanceof Error ? error.message : String(error)}`
}
],
isError: true
};
}
}
);
どちらの場合も Claude は、生の例外文字列の代わりに、失敗を説明する文を受け取ります。
TypeScript は z.string().url() で endpoint が URL 形式であることを検証していますが、Python の {"endpoint": str} は文字列であることしか見ていません。また、このツールは Claude が選んだ任意の URL に接続します。Claude が読んだ文書に悪意ある指示が紛れ込んでいた場合、社内ネットワークの URL に接続させられるおそれがあります。実際に使うなら、接続先をハンドラー内で許可リストと照合するか、第82回の PreToolUse フックで入力を確認するのが安全です(筆者の指摘)。
3-7. 画像とリソースを返す
content 配列には、text・image・audio・resource・resource_link の各ブロックを入れられ、1つの結果の中で混ぜても構いません。ただし、ブロックの種類によって言語ごとの扱いが違います。
| ブロック | TypeScript | Python |
|---|---|---|
audio | SDK がディスクに保存し、Claude は保存先のパスを含むテキストブロックを受け取る | SDK がツール結果から削除し、警告をログに出す |
resource_link | Claude はリンクの名前・URI・説明を含むテキストブロックを受け取る。アプリ側もユーザーメッセージの tool_use_result で resourceLinks として受け取る | Claude が受け取るものは同じ。ただし SDK が先にテキストに平らにするので、インプロセスのツールでは resourceLinks キーは作られない |
resource の blob(バイナリ) | 使える | SDK がツール結果から削除し、警告をログに出す |
Python で音声やバイナリのリソースを返しても、Claude には届きません。エラーにはならず警告が出るだけなので、ログを見ていないと気づきにくい点です。
画像
画像ブロックは、画像のバイト列を base64 にしてそのまま埋め込みます。URL を指定するフィールドはありません。 URL にある画像を返したいときは、ハンドラーの中で取得し、バイト列を読んで base64 にしてから返します。結果は画像として(視覚入力として)処理されます。
| フィールド | 型 | 注記 |
|---|---|---|
type | "image" | |
data | string | base64 にしたバイト列。生の base64 のみで、data:image/...;base64, の接頭辞は付けない |
mimeType | string | 必須。たとえば image/png・image/jpeg・image/webp・image/gif |
import base64
import httpx
from claude_agent_sdk import tool
# Define a tool that fetches an image from a URL and returns it to Claude
@tool("fetch_image", "Fetch an image from a URL and return it to Claude", {"url": str})
async def fetch_image(args):
async with httpx.AsyncClient() as client: # Fetch the image bytes
response = await client.get(args["url"])
return {
"content": [
{
"type": "image",
"data": base64.b64encode(response.content).decode(
"ascii"
), # Base64-encode the raw bytes
"mimeType": response.headers.get(
"content-type", "image/png"
), # Read MIME type from the response
}
]
}
import { tool } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
tool(
"fetch_image",
"Fetch an image from a URL and return it to Claude",
{
url: z.string().url()
},
async (args) => {
const response = await fetch(args.url); // Fetch the image bytes
const buffer = Buffer.from(await response.arrayBuffer()); // Read into a Buffer for base64 encoding
const mimeType = response.headers.get("content-type") ?? "image/png";
return {
content: [
{
type: "image",
data: buffer.toString("base64"), // Base64-encode the raw bytes
mimeType
}
]
};
}
);
どちらも、レスポンスの content-type ヘッダーから MIME タイプを取り、無ければ image/png にしています。このサンプルは HTTP ステータスを確認していないので、URL が見つからずエラーページ(HTML)が返ってきた場合も、それを画像として返してしまいます。実際には 3-6 と同じようにステータスを確認し、失敗なら isError を返すのがよいでしょう(筆者の指摘)。
リソース
リソースブロックは、URI で識別される内容を埋め込みます。URI は内容を参照するためのラベルで、実際の内容はブロックの text か blob に入っています。生成したファイルや外部システムのレコードのように、あとで名前で参照すると都合のよいものを返すときに使います。
| フィールド | 型 | 注記 |
|---|---|---|
type | "resource" | |
resource.uri | string | 内容の識別子。URI スキームは何でもよい |
resource.text | string | テキストの内容。これか blob のどちらか一方を入れる(両方は不可) |
resource.blob | string | バイナリの内容を base64 にしたもの。TypeScript のみ(Python SDK は削除して警告を出す) |
resource.mimeType | string | 任意 |
次の例はハンドラーから返すリソースブロックです。URI の file:///tmp/report.md は Claude があとで参照するためのラベルで、SDK はこのパスから何も読みません。
return {
content: [
{
type: "resource",
resource: {
uri: "file:///tmp/report.md", // Label for Claude to reference, not a path the SDK reads
mimeType: "text/markdown",
text: "# Report\n..." // The actual content, inline
}
}
]
};
return {
"content": [
{
"type": "resource",
"resource": {
"uri": "file:///tmp/report.md", # Label for Claude to reference, not a path the SDK reads
"mimeType": "text/markdown",
"text": "# Report\n...", # The actual content, inline
},
}
]
}
file:// で始まっていても実際のファイルとは無関係です。ファイルとして保存したいなら、ハンドラーの中で自分で書き込む必要があります。
これらのブロックの形は、MCP の CallToolResult 型から来ています。完全な定義は MCP の仕様にあります。
3-8. 構造化データを返す
structuredContent は、content 配列とは別に結果へ付ける任意の JSON オブジェクトです。文字列や画像から読み取らせる代わりに、Claude が正確なフィールドとして読める生の値を返すために使います。
ここに大事な挙動があります。structuredContent を設定すると、Claude が受け取るのは「その JSON」と「content の中の画像・リソースブロック」だけで、content のテキストブロックは渡されません。 テキストは構造化データと同じ内容を繰り返しているものと見なされるからです。content のテキストに JSON には無い説明を書いていても、Claude には届きません。
次の例は、グラフを画像ブロックとして返し、同じハンドラーから、その元になったデータ点を structuredContent で返します。chartPngBuffer は描画済みの PNG のバイト列を持つ Buffer です。
return {
content: [
{
type: "image",
data: chartPngBuffer.toString("base64"),
mimeType: "image/png"
}
],
structuredContent: {
series: "temperature_2m",
unit: "fahrenheit",
points: [62.1, 63.4, 65.0, 64.2]
}
};
Claude はこの場合、グラフの画像と、series・unit・points の JSON を受け取ります。
Python では、インプロセスのサーバーから structuredContent を返せません。 Python の @tool デコレーターは、ハンドラーの戻り値の dict から content と is_error しか転送しないためです。Python で structuredContent を返したいなら、インプロセスの SDK サーバーではなく、独立した MCP サーバーを動かします。
3-9. 例:単位変換ツール
長さ・温度・重さの単位を変換するツールです。ユーザーが「100キロメートルをマイルに変換して」「72°F は摂氏で何度?」と頼むと、Claude が依頼文から単位の種類と単位を選びます。示しているパターンは2つです。
- 列挙のスキーマ:
unit_typeを決まった値のどれかに制限します。TypeScript はz.enum()を使います。Python の単純な dict スキーマは列挙に対応していないので、完全な JSON Schema の dict が必要です。 - 対応していない入力の扱い:変換の組み合わせが見つからなければ、ハンドラーは
isError: trueを返します。Claude は失敗を普通の結果と取り違えず、何が問題だったかをユーザーに伝えられます。
from typing import Any
from claude_agent_sdk import tool, create_sdk_mcp_server
# z.enum() in TypeScript becomes an "enum" constraint in JSON Schema.
# The dict schema has no equivalent, so full JSON Schema is required.
@tool(
"convert_units",
"Convert a value from one unit to another",
{
"type": "object",
"properties": {
"unit_type": {
"type": "string",
"enum": ["length", "temperature", "weight"],
"description": "Category of unit",
},
"from_unit": {
"type": "string",
"description": "Unit to convert from, e.g. kilometers, fahrenheit, pounds",
},
"to_unit": {"type": "string", "description": "Unit to convert to"},
"value": {"type": "number", "description": "Value to convert"},
},
"required": ["unit_type", "from_unit", "to_unit", "value"],
},
)
async def convert_units(args: dict[str, Any]) -> dict[str, Any]:
conversions = {
"length": {
"kilometers_to_miles": lambda v: v * 0.621371,
"miles_to_kilometers": lambda v: v * 1.60934,
"meters_to_feet": lambda v: v * 3.28084,
"feet_to_meters": lambda v: v * 0.3048,
},
"temperature": {
"celsius_to_fahrenheit": lambda v: (v * 9) / 5 + 32,
"fahrenheit_to_celsius": lambda v: (v - 32) * 5 / 9,
"celsius_to_kelvin": lambda v: v + 273.15,
"kelvin_to_celsius": lambda v: v - 273.15,
},
"weight": {
"kilograms_to_pounds": lambda v: v * 2.20462,
"pounds_to_kilograms": lambda v: v * 0.453592,
"grams_to_ounces": lambda v: v * 0.035274,
"ounces_to_grams": lambda v: v * 28.3495,
},
}
key = f"{args['from_unit']}_to_{args['to_unit']}"
fn = conversions.get(args["unit_type"], {}).get(key)
if not fn:
return {
"content": [
{
"type": "text",
"text": f"Unsupported conversion: {args['from_unit']} to {args['to_unit']}",
}
],
"is_error": True,
}
result = fn(args["value"])
return {
"content": [
{
"type": "text",
"text": f"{args['value']} {args['from_unit']} = {result:.4f} {args['to_unit']}",
}
]
}
converter_server = create_sdk_mcp_server(
name="converter",
version="1.0.0",
tools=[convert_units],
)
import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
const convert = tool(
"convert_units",
"Convert a value from one unit to another",
{
unit_type: z.enum(["length", "temperature", "weight"]).describe("Category of unit"),
from_unit: z
.string()
.describe("Unit to convert from, e.g. kilometers, fahrenheit, pounds"),
to_unit: z.string().describe("Unit to convert to"),
value: z.number().describe("Value to convert")
},
async (args) => {
type Conversions = Record<string, Record<string, (v: number) => number>>;
const conversions: Conversions = {
length: {
kilometers_to_miles: (v) => v * 0.621371,
miles_to_kilometers: (v) => v * 1.60934,
meters_to_feet: (v) => v * 3.28084,
feet_to_meters: (v) => v * 0.3048
},
temperature: {
celsius_to_fahrenheit: (v) => (v * 9) / 5 + 32,
fahrenheit_to_celsius: (v) => ((v - 32) * 5) / 9,
celsius_to_kelvin: (v) => v + 273.15,
kelvin_to_celsius: (v) => v - 273.15
},
weight: {
kilograms_to_pounds: (v) => v * 2.20462,
pounds_to_kilograms: (v) => v * 0.453592,
grams_to_ounces: (v) => v * 0.035274,
ounces_to_grams: (v) => v * 28.3495
}
};
const key = `${args.from_unit}_to_${args.to_unit}`;
const fn = conversions[args.unit_type]?.[key];
if (!fn) {
return {
content: [
{
type: "text",
text: `Unsupported conversion: ${args.from_unit} to ${args.to_unit}`
}
],
isError: true
};
}
const result = fn(args.value);
return {
content: [
{
type: "text",
text: `${args.value} ${args.from_unit} = ${result.toFixed(4)} ${args.to_unit}`
}
]
};
}
);
const converterServer = createSdkMcpServer({
name: "converter",
version: "1.0.0",
tools: [convert]
});
Python 側の JSON Schema では、type: "object" の中に properties と required を書いています。4つのフィールドすべてを required にしているので、単純な dict と同じく全て必須です。各フィールドに description も付けられるので、TypeScript の .describe() と同じ情報を Claude に渡せます。
変換の対応表のキーは "{元の単位}_to_{先の単位}" の形です。したがって Claude は from_unit に kilometers、to_unit に miles のように、表のキーと同じ綴りで単位を渡す必要があります。km や Kilometers のような書き方では一致せず、「対応していない変換」としてエラーが返ります。from_unit の説明に e.g. kilometers, fahrenheit, pounds と例を書いているのは、この綴りに Claude を誘導するためと読めます(筆者の解釈)。エラーを受け取った Claude が綴りを直して再試行することも期待できます(3-6)。
サーバーを定義したら、天気の例と同じ方法で query に渡します。次の例は、同じツールで違う種類の単位を扱えることを示すために、3つの依頼をループで送ります。応答ごとに AssistantMessage(Claude がそのターンで行ったツール呼び出しを含む)を調べ、各 ToolUseBlock を表示してから、最後の ResultMessage の文章を表示します。これで、Claude がツールを使ったのか、自分の知識で答えたのかを確認できます。
ツール検索は既定で有効なので、Claude が遅延されたツールのスキーマを読み込むための ToolSearch の呼び出しも出力に現れることがあります。
import asyncio
from claude_agent_sdk import (
query,
ClaudeAgentOptions,
ResultMessage,
AssistantMessage,
ToolUseBlock,
)
async def main():
options = ClaudeAgentOptions(
mcp_servers={"converter": converter_server},
allowed_tools=["mcp__converter__convert_units"],
)
prompts = [
"Convert 100 kilometers to miles.",
"What is 72°F in Celsius?",
"How many pounds is 5 kilograms?",
]
for prompt in prompts:
try:
async for message in query(prompt=prompt, options=options):
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, ToolUseBlock):
print(f"[tool call] {block.name}({block.input})")
elif isinstance(message, ResultMessage) and message.subtype == "success":
print(f"Q: {prompt}\nA: {message.result}\n")
except Exception as error:
# A single-shot query() raises after yielding an error result. Only success
# results are printed above, so handle the failure here and continue with
# the next prompt.
print(f"Call failed: {error}")
asyncio.run(main())
import { query } from "@anthropic-ai/claude-agent-sdk";
const prompts = [
"Convert 100 kilometers to miles.",
"What is 72°F in Celsius?",
"How many pounds is 5 kilograms?"
];
for (const prompt of prompts) {
try {
for await (const message of query({
prompt,
options: {
mcpServers: { converter: converterServer },
allowedTools: ["mcp__converter__convert_units"]
}
})) {
if (message.type === "assistant") {
for (const block of message.message.content) {
if (block.type === "tool_use") {
console.log(`[tool call] ${block.name}`, block.input);
}
}
} else if (message.type === "result" && message.subtype === "success") {
console.log(`Q: ${prompt}\nA: ${message.result}\n`);
}
}
} catch (error) {
// A single-shot query() throws after yielding an error result. Only success
// results are logged above, so handle the failure here and continue with
// the next prompt.
console.error(`Call failed: ${error}`);
}
}
読むときの注意が2つあります。
- 1回きりの
query()は、エラー結果を返したあとで例外を投げます。 上のコードは成功の結果しか表示しないので、失敗はtry/except/try/catchで受けて、次の依頼に進んでいます。依頼を順に処理するループでは、この形にしないと1つの失敗で全体が止まります。 - TypeScript ではツール呼び出しを
message.message.contentから取り出しています。 第77回で指摘した TypeScript の二重構造です。Python はmessage.contentを直接見ています。
3-10. 次のステップ
このページのパターンは、1つのサーバーの中で組み合わせられます。1つのサーバーにデータベースのツール、API ゲートウェイのツール、画像の描画ツールを並べて持たせても構いません。原文は次の送り先を挙げています。
- サーバーのツールが数十個に増えたら、ツール検索で、Claude が必要とするまで読み込みを遅らせる
- 自作ではなく外部の MCP サーバー(ファイルシステム、GitHub、Slack)をつなぐなら、MCP サーバーの接続のページへ
- どのツールを自動で走らせ、どれに承認を求めるかは、権限の設定(第80回)へ
4. まとめ
- カスタムツールは、名前・説明・入力スキーマ・ハンドラーの4つで定義し、インプロセスの MCP サーバーに包んで
mcpServersに渡す。サーバーは別プロセスにならない。 - 説明は Claude がいつ呼ぶかを決める材料。TypeScript は
.describe()でフィールドごとの説明も渡せる。 - スキーマは TypeScript が Zod、Python が「名前→型」の dict。Python の dict は全キー必須で列挙も書けないので、省略可能な引数は
args.get()、列挙や範囲は完全な JSON Schema を使う。 - ツールの正式名は
mcp__{サーバーのキー}__{ツール名}。allowedToolsに書けば確認なしで走る。サーバー単位ならmcp__weather__*。フックのマッチャーにも同じ名前を使う。 - ツール検索は既定で有効で、自作ツールも遅延読み込みになる。TypeScript では
alwaysLoad: trueで最初から読み込める。 - 注釈のうち動作を変えるのは
readOnlyHint(並列実行)だけ。注釈は強制ではない。 toolsは組み込みツールだけを絞り、MCP ツールには影響しない。tools: []で自作ツールだけのエージェントになる。使わせたくないツールは、スコープ付き禁止ルールで止めるより、見えなくするほうが無駄がない。- ハンドラーの例外でループは止まらない。
isError(Python はis_error)で、Claude が読むエラー文を自分で書ける。 - Python では音声ブロックとバイナリのリソースが削除され、
structuredContentも返せない。画像は base64 で埋め込み、URL は使えない。リソースの URI はラベルで、SDK はそこを読まない。 structuredContentを設定すると、contentのテキストは Claude に渡らない。
次回予告
次回は 「MCP サーバーを接続する」(agent-sdk/mcp) を取り上げる予定です。
今回は、アプリの中で動く MCP サーバーを自分で作りました。次回は、すでに公開されている外部の MCP サーバー(ファイルシステム、GitHub、Slack など)を Agent SDK から接続する方法を見ていきます。今回「Python で structuredContent を返すなら独立した MCP サーバーが必要」と書いた、その独立したサーバーの扱い方もここで分かります。
※テーマは変更になる場合があります。

何か質問や相談があれば、コメントをお願いします。また、エンジニア案件の相談にも随時対応していますので、お気軽にお問い合わせください。
それでは、また明日お会いしましょう(^^)


コメント