agent-sdk/custom-tools.md +804 −0 added
1> ## Documentation Index
2> Fetch the complete documentation index at: https://code.claude.com/docs/llms.txt
3> Use this file to discover all available pages before exploring further.
4
5# Give Claude custom tools
6
7> Define custom tools with the Claude Agent SDK's in-process MCP server so Claude can call your functions, hit your APIs, and perform domain-specific operations.
8
9Custom tools extend the Agent SDK by letting you define your own functions that Claude can call during a conversation. Using the SDK's in-process MCP server, you can give Claude access to databases, external APIs, domain-specific logic, or any other capability your application needs.
10
11This guide covers how to define tools with input schemas and handlers, bundle them into an MCP server, pass them to `query`, and control which tools Claude can access. It also covers error handling, tool annotations, and returning non-text content like images.
12
13## Quick reference
14
15| If you want to... | Do this |
16| :------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
17| Define a tool | Use [`@tool`](/en/agent-sdk/python#tool) (Python) or [`tool()`](/en/agent-sdk/typescript#tool) (TypeScript) with a name, description, schema, and handler. See [Create a custom tool](#create-a-custom-tool). |
18| Register a tool with Claude | Wrap in `create_sdk_mcp_server` / `createSdkMcpServer` and pass to `mcpServers` in `query()`. See [Call a custom tool](#call-a-custom-tool). |
19| Pre-approve a tool | Add to your allowed tools. See [Configure allowed tools](#configure-allowed-tools). |
20| Remove a built-in tool from Claude's context | Pass a `tools` array listing only the built-ins you want. See [Configure allowed tools](#configure-allowed-tools). |
21| Let Claude call tools in parallel | Set `readOnlyHint: true` on tools with no side effects. See [Add tool annotations](#add-tool-annotations). |
22| Handle errors without stopping the loop | Return `isError: true` instead of throwing. See [Handle errors](#handle-errors). |
23| Return images or files | Use `image` or `resource` blocks in the content array. See [Return images and resources](#return-images-and-resources). |
24| Scale to many tools | Use [tool search](/en/agent-sdk/tool-search) to load tools on demand. |
25
26## Create a custom tool
27
28A tool is defined by four parts, passed as arguments to the [`tool()`](/en/agent-sdk/typescript#tool) helper in TypeScript or the [`@tool`](/en/agent-sdk/python#tool) decorator in Python:
29
30* **Name:** a unique identifier Claude uses to call the tool.
31* **Description:** what the tool does. Claude reads this to decide when to call it.
32* **Input schema:** the arguments Claude must provide. In TypeScript this is always a [Zod schema](https://zod.dev/), and the handler's `args` are typed from it automatically. In Python this is a dict mapping names to types, like `{"latitude": float}`, which the SDK converts to JSON Schema for you. The Python decorator also accepts a full [JSON Schema](https://json-schema.org/understanding-json-schema/about) dict directly when you need enums, ranges, optional fields, or nested objects.
33* **Handler:** the async function that runs when Claude calls the tool. It receives the validated arguments and must return an object with:
34 * `content` (required): an array of result blocks, each with a `type` of `"text"`, `"image"`, or `"resource"`. See [Return images and resources](#return-images-and-resources) for non-text blocks.
35 * `isError` (optional): set to `true` to signal a tool failure so Claude can react to it. See [Handle errors](#handle-errors).
36
37After defining a tool, wrap it in a server with [`createSdkMcpServer`](/en/agent-sdk/typescript#create-sdk-mcp-server) (TypeScript) or [`create_sdk_mcp_server`](/en/agent-sdk/python#create-sdk-mcp-server) (Python). The server runs in-process inside your application, not as a separate process.
38
39### Weather tool example
40
41This example defines a `get_temperature` tool and wraps it in an MCP server. It only sets up the tool; to pass it to `query` and run it, see [Call a custom tool](#call-a-custom-tool) below.
42
43<CodeGroup>
44 ```python Python theme={null}
45 from typing import Any
46 import httpx
47 from claude_agent_sdk import tool, create_sdk_mcp_server
48
49
50 # Define a tool: name, description, input schema, handler
51 @tool(
52 "get_temperature",
53 "Get the current temperature at a location",
54 {"latitude": float, "longitude": float},
55 )
56 async def get_temperature(args: dict[str, Any]) -> dict[str, Any]:
57 async with httpx.AsyncClient() as client:
58 response = await client.get(
59 "https://api.open-meteo.com/v1/forecast",
60 params={
61 "latitude": args["latitude"],
62 "longitude": args["longitude"],
63 "current": "temperature_2m",
64 "temperature_unit": "fahrenheit",
65 },
66 )
67 data = response.json()
68
69 # Return a content array - Claude sees this as the tool result
70 return {
71 "content": [
72 {
73 "type": "text",
74 "text": f"Temperature: {data['current']['temperature_2m']}°F",
75 }
76 ]
77 }
78
79
80 # Wrap the tool in an in-process MCP server
81 weather_server = create_sdk_mcp_server(
82 name="weather",
83 version="1.0.0",
84 tools=[get_temperature],
85 )
86 ```
87
88 ```typescript TypeScript theme={null}
89 import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
90 import { z } from "zod";
91
92 // Define a tool: name, description, input schema, handler
93 const getTemperature = tool(
94 "get_temperature",
95 "Get the current temperature at a location",
96 {
97 latitude: z.number().describe("Latitude coordinate"), // .describe() adds a field description Claude sees
98 longitude: z.number().describe("Longitude coordinate")
99 },
100 async (args) => {
101 // args is typed from the schema: { latitude: number; longitude: number }
102 const response = await fetch(
103 `https://api.open-meteo.com/v1/forecast?latitude=${args.latitude}&longitude=${args.longitude}¤t=temperature_2m&temperature_unit=fahrenheit`
104 );
105 const data: any = await response.json();
106
107 // Return a content array - Claude sees this as the tool result
108 return {
109 content: [{ type: "text", text: `Temperature: ${data.current.temperature_2m}°F` }]
110 };
111 }
112 );
113
114 // Wrap the tool in an in-process MCP server
115 const weatherServer = createSdkMcpServer({
116 name: "weather",
117 version: "1.0.0",
118 tools: [getTemperature]
119 });
120 ```
121</CodeGroup>
122
123See the [`tool()`](/en/agent-sdk/typescript#tool) TypeScript reference or the [`@tool`](/en/agent-sdk/python#tool) Python reference for full parameter details, including JSON Schema input formats and return value structure.
124
125<Tip>
126 To make a parameter optional: in TypeScript, add `.default()` to the Zod field. In Python, the dict schema treats every key as required, so leave the parameter out of the schema, mention it in the description string, and read it with `args.get()` in the handler. The [`get_precipitation_chance` tool below](#add-more-tools) shows both patterns.
127</Tip>
128
129### Call a custom tool
130
131Pass the MCP server you created to `query` via the `mcpServers` option. The key in `mcpServers` becomes the `{server_name}` segment in each tool's fully qualified name: `mcp__{server_name}__{tool_name}`. List that name in `allowedTools` so the tool runs without a permission prompt.
132
133These snippets reuse the `weatherServer` from the [example above](#weather-tool-example) to ask Claude what the weather is in a specific location.
134
135<CodeGroup>
136 ```python Python theme={null}
137 import asyncio
138 from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
139
140
141 async def main():
142 options = ClaudeAgentOptions(
143 mcp_servers={"weather": weather_server},
144 allowed_tools=["mcp__weather__get_temperature"],
145 )
146
147 async for message in query(
148 prompt="What's the temperature in San Francisco?",
149 options=options,
150 ):
151 # ResultMessage is the final message after all tool calls complete
152 if isinstance(message, ResultMessage) and message.subtype == "success":
153 print(message.result)
154
155
156 asyncio.run(main())
157 ```
158
159 ```typescript TypeScript theme={null}
160 import { query } from "@anthropic-ai/claude-agent-sdk";
161
162 for await (const message of query({
163 prompt: "What's the temperature in San Francisco?",
164 options: {
165 mcpServers: { weather: weatherServer },
166 allowedTools: ["mcp__weather__get_temperature"]
167 }
168 })) {
169 // "result" is the final message after all tool calls complete
170 if (message.type === "result" && message.subtype === "success") {
171 console.log(message.result);
172 }
173 }
174 ```
175</CodeGroup>
176
177### Add more tools
178
179A server holds as many tools as you list in its `tools` array. With more than one tool on a server, you can list each one in `allowedTools` individually or use the wildcard `mcp__weather__*` to cover every tool the server exposes.
180
181The example below adds a second tool, `get_precipitation_chance`, to the `weatherServer` from the [weather tool example](#weather-tool-example) and rebuilds it with both tools in the array.
182
183<CodeGroup>
184 ```python Python theme={null}
185 # Define a second tool for the same server
186 @tool(
187 "get_precipitation_chance",
188 "Get the hourly precipitation probability for a location. "
189 "Optionally pass 'hours' (1-24) to control how many hours to return.",
190 {"latitude": float, "longitude": float},
191 )
192 async def get_precipitation_chance(args: dict[str, Any]) -> dict[str, Any]:
193 # 'hours' isn't in the schema - read it with .get() to make it optional
194 hours = args.get("hours", 12)
195 async with httpx.AsyncClient() as client:
196 response = await client.get(
197 "https://api.open-meteo.com/v1/forecast",
198 params={
199 "latitude": args["latitude"],
200 "longitude": args["longitude"],
201 "hourly": "precipitation_probability",
202 "forecast_days": 1,
203 },
204 )
205 data = response.json()
206 chances = data["hourly"]["precipitation_probability"][:hours]
207
208 return {
209 "content": [
210 {
211 "type": "text",
212 "text": f"Next {hours} hours: {'%, '.join(map(str, chances))}%",
213 }
214 ]
215 }
216
217
218 # Rebuild the server with both tools in the array
219 weather_server = create_sdk_mcp_server(
220 name="weather",
221 version="1.0.0",
222 tools=[get_temperature, get_precipitation_chance],
223 )
224 ```
225
226 ```typescript TypeScript theme={null}
227 // Define a second tool for the same server
228 const getPrecipitationChance = tool(
229 "get_precipitation_chance",
230 "Get the hourly precipitation probability for a location",
231 {
232 latitude: z.number(),
233 longitude: z.number(),
234 hours: z
235 .number()
236 .int()
237 .min(1)
238 .max(24)
239 .default(12) // .default() makes the parameter optional
240 .describe("How many hours of forecast to return")
241 },
242 async (args) => {
243 const response = await fetch(
244 `https://api.open-meteo.com/v1/forecast?latitude=${args.latitude}&longitude=${args.longitude}&hourly=precipitation_probability&forecast_days=1`
245 );
246 const data: any = await response.json();
247 const chances = data.hourly.precipitation_probability.slice(0, args.hours);
248
249 return {
250 content: [{ type: "text", text: `Next ${args.hours} hours: ${chances.join("%, ")}%` }]
251 };
252 }
253 );
254
255 // Rebuild the server with both tools in the array
256 const weatherServer = createSdkMcpServer({
257 name: "weather",
258 version: "1.0.0",
259 tools: [getTemperature, getPrecipitationChance]
260 });
261 ```
262</CodeGroup>
263
264Every tool in this array consumes context window space on every turn. If you're defining dozens of tools, see [tool search](/en/agent-sdk/tool-search) to load them on demand instead.
265
266### Add tool annotations
267
268[Tool annotations](https://modelcontextprotocol.io/docs/concepts/tools#tool-annotations) are optional metadata describing how a tool behaves. Pass them as the fifth argument to `tool()` helper in TypeScript or via the `annotations` keyword argument for the `@tool` decorator in Python. All hint fields are Booleans.
269
270| Field | Default | Meaning |
271| :---------------- | :------ | :-------------------------------------------------------------------------------------------------------------------- |
272| `readOnlyHint` | `false` | Tool does not modify its environment. Controls whether the tool can be called in parallel with other read-only tools. |
273| `destructiveHint` | `true` | Tool may perform destructive updates. Informational only. |
274| `idempotentHint` | `false` | Repeated calls with the same arguments have no additional effect. Informational only. |
275| `openWorldHint` | `true` | Tool reaches systems outside your process. Informational only. |
276
277Annotations are metadata, not enforcement. A tool marked `readOnlyHint: true` can still write to disk if that's what the handler does. Keep the annotation accurate to the handler.
278
279This example adds `readOnlyHint` to the `get_temperature` tool from the [weather tool example](#weather-tool-example).
280
281<CodeGroup>
282 ```python Python theme={null}
283 from claude_agent_sdk import tool, ToolAnnotations
284
285
286 @tool(
287 "get_temperature",
288 "Get the current temperature at a location",
289 {"latitude": float, "longitude": float},
290 annotations=ToolAnnotations(
291 readOnlyHint=True
292 ), # Lets Claude batch this with other read-only calls
293 )
294 async def get_temperature(args):
295 return {"content": [{"type": "text", "text": "..."}]}
296 ```
297
298 ```typescript TypeScript theme={null}
299 tool(
300 "get_temperature",
301 "Get the current temperature at a location",
302 { latitude: z.number(), longitude: z.number() },
303 async (args) => ({ content: [{ type: "text", text: `...` }] }),
304 { annotations: { readOnlyHint: true } } // Lets Claude batch this with other read-only calls
305 );
306 ```
307</CodeGroup>
308
309See `ToolAnnotations` in the [TypeScript](/en/agent-sdk/typescript#tool-annotations) or [Python](/en/agent-sdk/python#tool-annotations) reference.
310
311## Control tool access
312
313The [weather tool example](#weather-tool-example) registered a server and listed tools in `allowedTools`. This section covers how tool names are constructed and how to scope access when you have multiple tools or want to restrict built-ins.
314
315### Tool name format
316
317When MCP tools are exposed to Claude, their names follow a specific format:
318
319* Pattern: `mcp__{server_name}__{tool_name}`
320* Example: A tool named `get_temperature` in server `weather` becomes `mcp__weather__get_temperature`
321
322### Configure allowed tools
323
324The `tools` option and the allowed/disallowed lists operate on separate layers. `tools` controls which built-in tools appear in Claude's context. Allowed and disallowed tool lists control whether calls are approved or denied once Claude attempts them.
325
326| Option | Layer | Effect |
327| :------------------------ | :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------ |
328| `tools: ["Read", "Grep"]` | Availability | Only the listed built-ins are in Claude's context. Unlisted built-ins are removed. MCP tools are unaffected. |
329| `tools: []` | Availability | All built-ins are removed. Claude can only use your MCP tools. |
330| allowed tools | Permission | Listed tools run without a permission prompt. Unlisted tools remain available; calls go through the [permission flow](/en/agent-sdk/permissions). |
331| disallowed tools | Permission | Every call to a listed tool is denied. The tool stays in Claude's context, so Claude may still attempt it before the call is rejected. |
332
333To limit which built-ins Claude can use, prefer `tools` over disallowed tools. Omitting a tool from `tools` removes it from context so Claude never attempts it; listing it in `disallowedTools` (Python: `disallowed_tools`) blocks the call but leaves the tool visible, so Claude may waste a turn trying it. See [Configure permissions](/en/agent-sdk/permissions) for the full evaluation order.
334
335## Handle errors
336
337How your handler reports errors determines whether the agent loop continues or stops:
338
339| What happens | Result |
340| :--------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------- |
341| Handler throws an uncaught exception | Agent loop stops. Claude never sees the error, and the `query` call fails. |
342| Handler catches the error and returns `isError: true` (TS) / `"is_error": True` (Python) | Agent loop continues. Claude sees the error as data and can retry, try a different tool, or explain the failure. |
343
344The example below catches two kinds of failures inside the handler instead of letting them throw. A non-200 HTTP status is caught from the response and returned as an error result. A network error or invalid JSON is caught by the surrounding `try/except` (Python) or `try/catch` (TypeScript) and also returned as an error result. In both cases the handler returns normally and the agent loop continues.
345
346<CodeGroup>
347 ```python Python theme={null}
348 import json
349 import httpx
350 from typing import Any
351
352
353 @tool(
354 "fetch_data",
355 "Fetch data from an API",
356 {"endpoint": str}, # Simple schema
357 )
358 async def fetch_data(args: dict[str, Any]) -> dict[str, Any]:
359 try:
360 async with httpx.AsyncClient() as client:
361 response = await client.get(args["endpoint"])
362 if response.status_code != 200:
363 # Return the failure as a tool result so Claude can react to it.
364 # is_error marks this as a failed call rather than odd-looking data.
365 return {
366 "content": [
367 {
368 "type": "text",
369 "text": f"API error: {response.status_code} {response.reason_phrase}",
370 }
371 ],
372 "is_error": True,
373 }
374
375 data = response.json()
376 return {"content": [{"type": "text", "text": json.dumps(data, indent=2)}]}
377 except Exception as e:
378 # Catching here keeps the agent loop alive. An uncaught exception
379 # would end the whole query() call.
380 return {
381 "content": [{"type": "text", "text": f"Failed to fetch data: {str(e)}"}],
382 "is_error": True,
383 }
384 ```
385
386 ```typescript TypeScript theme={null}
387 tool(
388 "fetch_data",
389 "Fetch data from an API",
390 {
391 endpoint: z.string().url().describe("API endpoint URL")
392 },
393 async (args) => {
394 try {
395 const response = await fetch(args.endpoint);
396
397 if (!response.ok) {
398 // Return the failure as a tool result so Claude can react to it.
399 // isError marks this as a failed call rather than odd-looking data.
400 return {
401 content: [
402 {
403 type: "text",
404 text: `API error: ${response.status} ${response.statusText}`
405 }
406 ],
407 isError: true
408 };
409 }
410
411 const data = await response.json();
412 return {
413 content: [
414 {
415 type: "text",
416 text: JSON.stringify(data, null, 2)
417 }
418 ]
419 };
420 } catch (error) {
421 // Catching here keeps the agent loop alive. An uncaught throw
422 // would end the whole query() call.
423 return {
424 content: [
425 {
426 type: "text",
427 text: `Failed to fetch data: ${error instanceof Error ? error.message : String(error)}`
428 }
429 ],
430 isError: true
431 };
432 }
433 }
434 );
435 ```
436</CodeGroup>
437
438## Return images and resources
439
440The `content` array in a tool result accepts `text`, `image`, and `resource` blocks. You can mix them in the same response.
441
442### Images
443
444An image block carries the image bytes inline, encoded as base64. There is no URL field. To return an image that lives at a URL, fetch it in the handler, read the response bytes, and base64-encode them before returning. The result is processed as visual input.
445
446| Field | Type | Notes |
447| :--------- | :-------- | :------------------------------------------------------------------------- |
448| `type` | `"image"` | |
449| `data` | `string` | Base64-encoded bytes. Raw base64 only, no `data:image/...;base64,` prefix |
450| `mimeType` | `string` | Required. For example `image/png`, `image/jpeg`, `image/webp`, `image/gif` |
451
452<CodeGroup>
453 ```python Python theme={null}
454 import base64
455 import httpx
456
457
458 # Define a tool that fetches an image from a URL and returns it to Claude
459 @tool("fetch_image", "Fetch an image from a URL and return it to Claude", {"url": str})
460 async def fetch_image(args):
461 async with httpx.AsyncClient() as client: # Fetch the image bytes
462 response = await client.get(args["url"])
463
464 return {
465 "content": [
466 {
467 "type": "image",
468 "data": base64.b64encode(response.content).decode(
469 "ascii"
470 ), # Base64-encode the raw bytes
471 "mimeType": response.headers.get(
472 "content-type", "image/png"
473 ), # Read MIME type from the response
474 }
475 ]
476 }
477 ```
478
479 ```typescript TypeScript theme={null}
480 tool(
481 "fetch_image",
482 "Fetch an image from a URL and return it to Claude",
483 {
484 url: z.string().url()
485 },
486 async (args) => {
487 const response = await fetch(args.url); // Fetch the image bytes
488 const buffer = Buffer.from(await response.arrayBuffer()); // Read into a Buffer for base64 encoding
489 const mimeType = response.headers.get("content-type") ?? "image/png";
490
491 return {
492 content: [
493 {
494 type: "image",
495 data: buffer.toString("base64"), // Base64-encode the raw bytes
496 mimeType
497 }
498 ]
499 };
500 }
501 );
502 ```
503</CodeGroup>
504
505### Resources
506
507A resource block embeds a piece of content identified by a URI. The URI is a label for Claude to reference; the actual content rides in the block's `text` or `blob` field. Use this when your tool produces something that makes sense to address by name later, such as a generated file or a record from an external system.
508
509| Field | Type | Notes |
510| :------------------ | :----------- | :---------------------------------------------------------- |
511| `type` | `"resource"` | |
512| `resource.uri` | `string` | Identifier for the content. Any URI scheme |
513| `resource.text` | `string` | The content, if it's text. Provide this or `blob`, not both |
514| `resource.blob` | `string` | The content base64-encoded, if it's binary |
515| `resource.mimeType` | `string` | Optional |
516
517This example shows a resource block returned from inside a tool handler. The URI `file:///tmp/report.md` is a label that Claude can reference later; the SDK does not read from that path.
518
519<CodeGroup>
520 ```typescript TypeScript theme={null}
521 return {
522 content: [
523 {
524 type: "resource",
525 resource: {
526 uri: "file:///tmp/report.md", // Label for Claude to reference, not a path the SDK reads
527 mimeType: "text/markdown",
528 text: "# Report\n..." // The actual content, inline
529 }
530 }
531 ]
532 };
533 ```
534
535 ```python Python theme={null}
536 return {
537 "content": [
538 {
539 "type": "resource",
540 "resource": {
541 "uri": "file:///tmp/report.md", # Label for Claude to reference, not a path the SDK reads
542 "mimeType": "text/markdown",
543 "text": "# Report\n...", # The actual content, inline
544 },
545 }
546 ]
547 }
548 ```
549</CodeGroup>
550
551These block shapes come from the MCP `CallToolResult` type. See the [MCP specification](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#tool-result) for the full definition.
552
553## Example: unit converter
554
555This tool converts values between units of length, temperature, and weight. A user can ask "convert 100 kilometers to miles" or "what is 72°F in Celsius," and Claude picks the right unit type and units from the request.
556
557It demonstrates two patterns:
558
559* **Enum schemas:** `unit_type` is constrained to a fixed set of values. In TypeScript, use `z.enum()`. In Python, the dict schema doesn't support enums, so the full JSON Schema dict is required.
560* **Unsupported input handling:** when a conversion pair isn't found, the handler returns `isError: true` so Claude can tell the user what went wrong rather than treating a failure as a normal result.
561
562<CodeGroup>
563 ```python Python theme={null}
564 from typing import Any
565 from claude_agent_sdk import tool, create_sdk_mcp_server
566
567
568 # z.enum() in TypeScript becomes an "enum" constraint in JSON Schema.
569 # The dict schema has no equivalent, so full JSON Schema is required.
570 @tool(
571 "convert_units",
572 "Convert a value from one unit to another",
573 {
574 "type": "object",
575 "properties": {
576 "unit_type": {
577 "type": "string",
578 "enum": ["length", "temperature", "weight"],
579 "description": "Category of unit",
580 },
581 "from_unit": {
582 "type": "string",
583 "description": "Unit to convert from, e.g. kilometers, fahrenheit, pounds",
584 },
585 "to_unit": {"type": "string", "description": "Unit to convert to"},
586 "value": {"type": "number", "description": "Value to convert"},
587 },
588 "required": ["unit_type", "from_unit", "to_unit", "value"],
589 },
590 )
591 async def convert_units(args: dict[str, Any]) -> dict[str, Any]:
592 conversions = {
593 "length": {
594 "kilometers_to_miles": lambda v: v * 0.621371,
595 "miles_to_kilometers": lambda v: v * 1.60934,
596 "meters_to_feet": lambda v: v * 3.28084,
597 "feet_to_meters": lambda v: v * 0.3048,
598 },
599 "temperature": {
600 "celsius_to_fahrenheit": lambda v: (v * 9) / 5 + 32,
601 "fahrenheit_to_celsius": lambda v: (v - 32) * 5 / 9,
602 "celsius_to_kelvin": lambda v: v + 273.15,
603 "kelvin_to_celsius": lambda v: v - 273.15,
604 },
605 "weight": {
606 "kilograms_to_pounds": lambda v: v * 2.20462,
607 "pounds_to_kilograms": lambda v: v * 0.453592,
608 "grams_to_ounces": lambda v: v * 0.035274,
609 "ounces_to_grams": lambda v: v * 28.3495,
610 },
611 }
612
613 key = f"{args['from_unit']}_to_{args['to_unit']}"
614 fn = conversions.get(args["unit_type"], {}).get(key)
615
616 if not fn:
617 return {
618 "content": [
619 {
620 "type": "text",
621 "text": f"Unsupported conversion: {args['from_unit']} to {args['to_unit']}",
622 }
623 ],
624 "is_error": True,
625 }
626
627 result = fn(args["value"])
628 return {
629 "content": [
630 {
631 "type": "text",
632 "text": f"{args['value']} {args['from_unit']} = {result:.4f} {args['to_unit']}",
633 }
634 ]
635 }
636
637
638 converter_server = create_sdk_mcp_server(
639 name="converter",
640 version="1.0.0",
641 tools=[convert_units],
642 )
643 ```
644
645 ```typescript TypeScript theme={null}
646 import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
647 import { z } from "zod";
648
649 const convert = tool(
650 "convert_units",
651 "Convert a value from one unit to another",
652 {
653 unit_type: z.enum(["length", "temperature", "weight"]).describe("Category of unit"),
654 from_unit: z
655 .string()
656 .describe("Unit to convert from, e.g. kilometers, fahrenheit, pounds"),
657 to_unit: z.string().describe("Unit to convert to"),
658 value: z.number().describe("Value to convert")
659 },
660 async (args) => {
661 type Conversions = Record<string, Record<string, (v: number) => number>>;
662
663 const conversions: Conversions = {
664 length: {
665 kilometers_to_miles: (v) => v * 0.621371,
666 miles_to_kilometers: (v) => v * 1.60934,
667 meters_to_feet: (v) => v * 3.28084,
668 feet_to_meters: (v) => v * 0.3048
669 },
670 temperature: {
671 celsius_to_fahrenheit: (v) => (v * 9) / 5 + 32,
672 fahrenheit_to_celsius: (v) => ((v - 32) * 5) / 9,
673 celsius_to_kelvin: (v) => v + 273.15,
674 kelvin_to_celsius: (v) => v - 273.15
675 },
676 weight: {
677 kilograms_to_pounds: (v) => v * 2.20462,
678 pounds_to_kilograms: (v) => v * 0.453592,
679 grams_to_ounces: (v) => v * 0.035274,
680 ounces_to_grams: (v) => v * 28.3495
681 }
682 };
683
684 const key = `${args.from_unit}_to_${args.to_unit}`;
685 const fn = conversions[args.unit_type]?.[key];
686
687 if (!fn) {
688 return {
689 content: [
690 {
691 type: "text",
692 text: `Unsupported conversion: ${args.from_unit} to ${args.to_unit}`
693 }
694 ],
695 isError: true
696 };
697 }
698
699 const result = fn(args.value);
700 return {
701 content: [
702 {
703 type: "text",
704 text: `${args.value} ${args.from_unit} = ${result.toFixed(4)} ${args.to_unit}`
705 }
706 ]
707 };
708 }
709 );
710
711 const converterServer = createSdkMcpServer({
712 name: "converter",
713 version: "1.0.0",
714 tools: [convert]
715 });
716 ```
717</CodeGroup>
718
719Once the server is defined, pass it to `query` the same way as the weather example. This example sends three different prompts in a loop to show the same tool handling different unit types. For each response, it inspects `AssistantMessage` objects (which contain the tool calls Claude made during that turn) and prints each `ToolUseBlock` before printing the final `ResultMessage` text. This lets you see when Claude is using the tool versus answering from its own knowledge.
720
721<CodeGroup>
722 ```python Python theme={null}
723 import asyncio
724 from claude_agent_sdk import (
725 query,
726 ClaudeAgentOptions,
727 ResultMessage,
728 AssistantMessage,
729 ToolUseBlock,
730 )
731
732
733 async def main():
734 options = ClaudeAgentOptions(
735 mcp_servers={"converter": converter_server},
736 allowed_tools=["mcp__converter__convert_units"],
737 )
738
739 prompts = [
740 "Convert 100 kilometers to miles.",
741 "What is 72°F in Celsius?",
742 "How many pounds is 5 kilograms?",
743 ]
744
745 for prompt in prompts:
746 async for message in query(prompt=prompt, options=options):
747 if isinstance(message, AssistantMessage):
748 for block in message.content:
749 if isinstance(block, ToolUseBlock):
750 print(f"[tool call] {block.name}({block.input})")
751 elif isinstance(message, ResultMessage) and message.subtype == "success":
752 print(f"Q: {prompt}\nA: {message.result}\n")
753
754
755 asyncio.run(main())
756 ```
757
758 ```typescript TypeScript theme={null}
759 import { query } from "@anthropic-ai/claude-agent-sdk";
760
761 const prompts = [
762 "Convert 100 kilometers to miles.",
763 "What is 72°F in Celsius?",
764 "How many pounds is 5 kilograms?"
765 ];
766
767 for (const prompt of prompts) {
768 for await (const message of query({
769 prompt,
770 options: {
771 mcpServers: { converter: converterServer },
772 allowedTools: ["mcp__converter__convert_units"]
773 }
774 })) {
775 if (message.type === "assistant") {
776 for (const block of message.message.content) {
777 if (block.type === "tool_use") {
778 console.log(`[tool call] ${block.name}`, block.input);
779 }
780 }
781 } else if (message.type === "result" && message.subtype === "success") {
782 console.log(`Q: ${prompt}\nA: ${message.result}\n`);
783 }
784 }
785 }
786 ```
787</CodeGroup>
788
789## Next steps
790
791Custom tools wrap async functions in a standard interface. You can mix the patterns on this page in the same server: a single server can hold a database tool, an API gateway tool, and an image renderer alongside each other.
792
793From here:
794
795* If your server grows to dozens of tools, see [tool search](/en/agent-sdk/tool-search) to defer loading them until Claude needs them.
796* To connect to external MCP servers (filesystem, GitHub, Slack) instead of building your own, see [Connect MCP servers](/en/agent-sdk/mcp).
797* To control which tools run automatically versus requiring approval, see [Configure permissions](/en/agent-sdk/permissions).
798
799## Related documentation
800
801* [TypeScript SDK Reference](/en/agent-sdk/typescript)
802* [Python SDK Reference](/en/agent-sdk/python)
803* [MCP Documentation](https://modelcontextprotocol.io)
804* [SDK Overview](/en/agent-sdk/overview)