【Claude Code 連載 第83回】Claude にカスタムツールを提供する(agent-sdk/custom-tools)

スポンサーリンク
【Claude Code 連載 第83回】Claude にカスタムツールを提供する(agent-sdk/custom-tools) 用語解説
【Claude Code 連載 第83回】Claude にカスタムツールを提供する(agent-sdk/custom-tools)
この記事は約52分で読めます。
よっしー
よっしー

こんにちは。よっしーです(^^)

スポンサーリンク

背景

この連載では、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}&current=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 キーワード引数で渡します。値はすべて真偽値です。

フィールド既定値意味
readOnlyHintfalseツールは環境を変えない。他の読み取り専用ツールと並列で呼べるかどうかを左右する
destructiveHinttrueツールは破壊的な更新をしうる。情報として示すだけ
idempotentHintfalse同じ引数で何度呼んでも追加の影響がない。情報として示すだけ
openWorldHinttrueツールはプロセスの外のシステムに接続する。情報として示すだけ

実際に動作を変えるのは 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つの結果の中で混ぜても構いません。ただし、ブロックの種類によって言語ごとの扱いが違います。

ブロックTypeScriptPython
audioSDK がディスクに保存し、Claude は保存先のパスを含むテキストブロックを受け取るSDK がツール結果から削除し、警告をログに出す
resource_linkClaude はリンクの名前・URI・説明を含むテキストブロックを受け取る。アプリ側もユーザーメッセージの tool_use_result で resourceLinks として受け取るClaude が受け取るものは同じ。ただし SDK が先にテキストに平らにするので、インプロセスのツールでは resourceLinks キーは作られない
resource の blob(バイナリ)使えるSDK がツール結果から削除し、警告をログに出す

Python で音声やバイナリのリソースを返しても、Claude には届きません。エラーにはならず警告が出るだけなので、ログを見ていないと気づきにくい点です。

画像

画像ブロックは、画像のバイト列を base64 にしてそのまま埋め込みます。URL を指定するフィールドはありません。 URL にある画像を返したいときは、ハンドラーの中で取得し、バイト列を読んで base64 にしてから返します。結果は画像として(視覚入力として)処理されます。

フィールド型注記
type"image"
datastringbase64 にしたバイト列。生の base64 のみで、data:image/...;base64, の接頭辞は付けない
mimeTypestring必須。たとえば 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.uristring内容の識別子。URI スキームは何でもよい
resource.textstringテキストの内容。これか blob のどちらか一方を入れる(両方は不可)
resource.blobstringバイナリの内容を base64 にしたもの。TypeScript のみ(Python SDK は削除して警告を出す)
resource.mimeTypestring任意

次の例はハンドラーから返すリソースブロックです。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 サーバーが必要」と書いた、その独立したサーバーの扱い方もここで分かります。

※テーマは変更になる場合があります。


よっしー
よっしー

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

それでは、また明日お会いしましょう(^^)

コメント

タイトルとURLをコピーしました