feat(phase1): add image storage utilities

- Create backend/services/image_storage.py with 4 core functions:
  - sanitize_filename(): remove unsafe chars, limit to 255 chars, convert to lowercase
  - get_unique_filename(): handle collisions with UUID suffix (format: {name}_{uuid8}_{variant}.jpg)
  - ensure_image_directories(): create /images/ root and category subdirs on startup
  - save_image(): save bytes to /images/{category}/{filename}, returns relative path
- Create comprehensive test suite (22 tests) covering all functionality
- Integrate ensure_image_directories() into FastAPI startup event
- Directory structure: /images/{category}/{filename}
- Collision handling: auto-suffix with UUID if filename exists
- All tests passing, pathlib.Path for safe operations
This commit is contained in:
2026-04-20 21:57:26 +03:00
parent 39fab336ba
commit ea49cd6e4a
4487 changed files with 1305959 additions and 4 deletions

View File

@@ -0,0 +1,29 @@
from ._beta_runner import BetaToolRunner, BetaAsyncToolRunner, BetaStreamingToolRunner, BetaAsyncStreamingToolRunner
from ._beta_functions import (
ToolError,
BetaFunctionTool,
BetaAsyncFunctionTool,
BetaBuiltinFunctionTool,
BetaFunctionToolResultType,
BetaAsyncBuiltinFunctionTool,
beta_tool,
beta_async_tool,
)
from ._beta_builtin_memory_tool import BetaAbstractMemoryTool, BetaAsyncAbstractMemoryTool
__all__ = [
"beta_tool",
"beta_async_tool",
"BetaFunctionTool",
"BetaAsyncFunctionTool",
"BetaBuiltinFunctionTool",
"BetaAsyncBuiltinFunctionTool",
"BetaToolRunner",
"BetaAsyncStreamingToolRunner",
"BetaStreamingToolRunner",
"BetaAsyncToolRunner",
"BetaFunctionToolResultType",
"BetaAbstractMemoryTool",
"BetaAsyncAbstractMemoryTool",
"ToolError",
]

View File

@@ -0,0 +1,875 @@
from __future__ import annotations
import os
import uuid
import shutil
from abc import abstractmethod
from typing import TYPE_CHECKING, Any, List, cast
from pathlib import Path
from typing_extensions import override, assert_never
from anyio import Path as AsyncPath
from anyio.to_thread import run_sync
from anthropic.types.beta import (
BetaMemoryTool20250818ViewCommand,
BetaMemoryTool20250818CreateCommand,
BetaMemoryTool20250818DeleteCommand,
BetaMemoryTool20250818InsertCommand,
BetaMemoryTool20250818RenameCommand,
BetaMemoryTool20250818StrReplaceCommand,
)
from ..._models import construct_type_unchecked
from ...types.beta import (
BetaMemoryTool20250818Param,
BetaMemoryTool20250818Command,
BetaCacheControlEphemeralParam,
BetaMemoryTool20250818ViewCommand,
BetaMemoryTool20250818CreateCommand,
BetaMemoryTool20250818DeleteCommand,
BetaMemoryTool20250818InsertCommand,
BetaMemoryTool20250818RenameCommand,
BetaMemoryTool20250818StrReplaceCommand,
)
from ._beta_functions import (
ToolError,
BetaBuiltinFunctionTool,
BetaFunctionToolResultType,
BetaAsyncBuiltinFunctionTool,
)
MAX_LINES = 999999
LINE_NUMBER_WIDTH = len(str(MAX_LINES))
# Owner read/write only. Avoids 0o666 which, in environments with a permissive
# umask (e.g. Docker where umask is often 0o000), would make memory files
# world-readable or even world-writable.
_FILE_CREATE_MODE = 0o600
# The default mkdir mode is 0o777, but we want to be more restrictive for memory
# directories to avoid them being world-accessible in environments with permissive umasks
# (eg Docker)
_DIR_CREATE_MODE = 0o700
class BetaAbstractMemoryTool(BetaBuiltinFunctionTool):
"""Abstract base class for memory tool implementations.
This class provides the interface for implementing a custom memory backend for Claude.
Subclass this to create your own memory storage solution (e.g., database, cloud storage, encrypted files, etc.).
Example usage:
```py
class MyMemoryTool(BetaAbstractMemoryTool):
def view(self, command: BetaMemoryTool20250818ViewCommand) -> BetaFunctionToolResultType:
...
return "view result"
def create(self, command: BetaMemoryTool20250818CreateCommand) -> BetaFunctionToolResultType:
...
return "created successfully"
# ... implement other abstract methods
client = Anthropic()
memory_tool = MyMemoryTool()
message = client.beta.messages.run_tools(
model="claude-sonnet-4-5",
messages=[{"role": "user", "content": "Remember that I like coffee"}],
tools=[memory_tool],
).until_done()
```
"""
def __init__(self, *, cache_control: BetaCacheControlEphemeralParam | None = None) -> None:
super().__init__()
self._cache_control = cache_control
@override
def to_dict(self) -> BetaMemoryTool20250818Param:
param: BetaMemoryTool20250818Param = {"type": "memory_20250818", "name": "memory"}
if self._cache_control is not None:
param["cache_control"] = self._cache_control
return param
@override
def call(self, input: object) -> BetaFunctionToolResultType:
command = cast(
BetaMemoryTool20250818Command,
construct_type_unchecked(value=input, type_=cast(Any, BetaMemoryTool20250818Command)),
)
return self.execute(command)
def execute(self, command: BetaMemoryTool20250818Command) -> BetaFunctionToolResultType:
"""Execute a memory command and return the result.
This method dispatches to the appropriate handler method based on the
command type (view, create, str_replace, insert, delete, rename).
You typically don't need to override this method.
"""
if command.command == "view":
return self.view(command)
elif command.command == "create":
return self.create(command)
elif command.command == "str_replace":
return self.str_replace(command)
elif command.command == "insert":
return self.insert(command)
elif command.command == "delete":
return self.delete(command)
elif command.command == "rename":
return self.rename(command)
elif TYPE_CHECKING: # type: ignore[unreachable]
assert_never(command)
else:
raise NotImplementedError(f"Unknown command: {command.command}")
@abstractmethod
def view(self, command: BetaMemoryTool20250818ViewCommand) -> BetaFunctionToolResultType:
"""View the contents of a memory path."""
pass
@abstractmethod
def create(self, command: BetaMemoryTool20250818CreateCommand) -> BetaFunctionToolResultType:
"""Create a new memory file with the specified content."""
pass
@abstractmethod
def str_replace(self, command: BetaMemoryTool20250818StrReplaceCommand) -> BetaFunctionToolResultType:
"""Replace text in a memory file."""
pass
@abstractmethod
def insert(self, command: BetaMemoryTool20250818InsertCommand) -> BetaFunctionToolResultType:
"""Insert text at a specific line number in a memory file."""
pass
@abstractmethod
def delete(self, command: BetaMemoryTool20250818DeleteCommand) -> BetaFunctionToolResultType:
"""Delete a memory file or directory."""
pass
@abstractmethod
def rename(self, command: BetaMemoryTool20250818RenameCommand) -> BetaFunctionToolResultType:
"""Rename or move a memory file or directory."""
pass
def clear_all_memory(self) -> BetaFunctionToolResultType:
"""Clear all memory data."""
raise NotImplementedError("clear_all_memory not implemented")
class BetaAsyncAbstractMemoryTool(BetaAsyncBuiltinFunctionTool):
"""Abstract base class for memory tool implementations.
This class provides the interface for implementing a custom memory backend for Claude.
Subclass this to create your own memory storage solution (e.g., database, cloud storage, encrypted files, etc.).
Example usage:
```py
class MyMemoryTool(BetaAbstractMemoryTool):
def view(self, command: BetaMemoryTool20250818ViewCommand) -> BetaFunctionToolResultType:
...
return "view result"
def create(self, command: BetaMemoryTool20250818CreateCommand) -> BetaFunctionToolResultType:
...
return "created successfully"
# ... implement other abstract methods
client = Anthropic()
memory_tool = MyMemoryTool()
message = client.beta.messages.run_tools(
model="claude-sonnet-4-5",
messages=[{"role": "user", "content": "Remember that I like coffee"}],
tools=[memory_tool],
).until_done()
```
"""
def __init__(self, *, cache_control: BetaCacheControlEphemeralParam | None = None) -> None:
super().__init__()
self._cache_control = cache_control
@override
def to_dict(self) -> BetaMemoryTool20250818Param:
param: BetaMemoryTool20250818Param = {"type": "memory_20250818", "name": "memory"}
if self._cache_control is not None:
param["cache_control"] = self._cache_control
return param
@override
async def call(self, input: object) -> BetaFunctionToolResultType:
command = cast(
BetaMemoryTool20250818Command,
construct_type_unchecked(value=input, type_=cast(Any, BetaMemoryTool20250818Command)),
)
return await self.execute(command)
async def execute(self, command: BetaMemoryTool20250818Command) -> BetaFunctionToolResultType:
"""Execute a memory command and return the result.
This method dispatches to the appropriate handler method based on the
command type (view, create, str_replace, insert, delete, rename).
You typically don't need to override this method.
"""
if command.command == "view":
return await self.view(command)
elif command.command == "create":
return await self.create(command)
elif command.command == "str_replace":
return await self.str_replace(command)
elif command.command == "insert":
return await self.insert(command)
elif command.command == "delete":
return await self.delete(command)
elif command.command == "rename":
return await self.rename(command)
elif TYPE_CHECKING: # type: ignore[unreachable]
assert_never(command)
else:
raise NotImplementedError(f"Unknown command: {command.command}")
@abstractmethod
async def view(self, command: BetaMemoryTool20250818ViewCommand) -> BetaFunctionToolResultType:
"""View the contents of a memory path."""
pass
@abstractmethod
async def create(self, command: BetaMemoryTool20250818CreateCommand) -> BetaFunctionToolResultType:
"""Create a new memory file with the specified content."""
pass
@abstractmethod
async def str_replace(self, command: BetaMemoryTool20250818StrReplaceCommand) -> BetaFunctionToolResultType:
"""Replace text in a memory file."""
pass
@abstractmethod
async def insert(self, command: BetaMemoryTool20250818InsertCommand) -> BetaFunctionToolResultType:
"""Insert text at a specific line number in a memory file."""
pass
@abstractmethod
async def delete(self, command: BetaMemoryTool20250818DeleteCommand) -> BetaFunctionToolResultType:
"""Delete a memory file or directory."""
pass
@abstractmethod
async def rename(self, command: BetaMemoryTool20250818RenameCommand) -> BetaFunctionToolResultType:
"""Rename or move a memory file or directory."""
pass
async def clear_all_memory(self) -> BetaFunctionToolResultType:
"""Clear all memory data."""
raise NotImplementedError("clear_all_memory not implemented")
def _atomic_write_file(target_path: Path, content: str) -> None:
dir_path = target_path.parent
temp_path = dir_path / f".tmp-{os.getpid()}-{uuid.uuid4()}"
data = content.encode("utf-8")
try:
fd = os.open(temp_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, _FILE_CREATE_MODE)
try:
offset = 0
while offset < len(data):
written = os.write(fd, data[offset:])
if written == 0:
raise OSError("os.write returned 0")
offset += written
os.fsync(fd)
finally:
os.close(fd)
os.replace(temp_path, target_path)
except Exception:
temp_path.unlink(missing_ok=True)
raise
def _validate_no_symlink_escape(target_path: Path, memory_root: Path) -> None:
resolved_root = memory_root.resolve()
current = target_path
while True:
try:
resolved = current.resolve()
if resolved != resolved_root and not str(resolved).startswith(str(resolved_root) + os.sep):
raise ToolError("Path would escape /memories directory via symlink")
return
except (FileNotFoundError, OSError):
parent = current.parent
if parent == current or current == memory_root:
return
current = parent
def _read_file_content(full_path: Path, memory_path: str) -> str:
try:
return full_path.read_text(encoding="utf-8")
except FileNotFoundError as err:
raise ToolError(
f"The file {memory_path} no longer exists (may have been deleted or renamed concurrently)."
) from err
def _format_file_size(bytes_size: int) -> str:
if bytes_size == 0:
return "0B"
k = 1024
sizes = ["B", "K", "M", "G"]
i = int(bytes_size.bit_length() - 1) // 10
i = min(i, len(sizes) - 1)
size = bytes_size / (k**i)
if size == int(size):
return f"{int(size)}{sizes[i]}"
else:
return f"{size:.1f}{sizes[i]}"
class BetaLocalFilesystemMemoryTool(BetaAbstractMemoryTool):
"""File-based memory storage implementation for Claude conversations"""
def __init__(self, base_path: str = "./memory"):
super().__init__()
self.base_path = Path(base_path)
self.memory_root = self.base_path / "memories"
self.memory_root.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
def _validate_path(self, path: str) -> Path:
"""Validate and resolve memory paths"""
if not path.startswith("/memories"):
raise ToolError(f"Path must start with /memories, got: {path}")
relative_path = path[len("/memories") :].lstrip("/")
full_path = self.memory_root / relative_path if relative_path else self.memory_root
resolved_path = full_path.resolve()
resolved_root = self.memory_root.resolve()
if resolved_path != resolved_root and not str(resolved_path).startswith(str(resolved_root) + os.sep):
raise ToolError(f"Path {path} would escape /memories directory")
_validate_no_symlink_escape(resolved_path, self.memory_root)
return resolved_path
@override
def view(self, command: BetaMemoryTool20250818ViewCommand) -> str:
full_path = self._validate_path(command.path)
if not full_path.exists():
raise ToolError(f"The path {command.path} does not exist. Please provide a valid path.")
if full_path.is_dir():
items: List[tuple[str, str]] = []
def collect_items(dir_path: Path, relative_path: str, depth: int) -> None:
if depth > 2:
return
try:
dir_contents = sorted(dir_path.iterdir(), key=lambda x: x.name)
except Exception:
return
for item in dir_contents:
if item.name.startswith("."):
continue
item_relative_path = f"{relative_path}/{item.name}" if relative_path else item.name
try:
stat = item.stat()
except Exception:
continue
if item.is_dir():
items.append((_format_file_size(stat.st_size), f"{item_relative_path}/"))
if depth < 2:
collect_items(item, item_relative_path, depth + 1)
elif item.is_file():
items.append((_format_file_size(stat.st_size), item_relative_path))
collect_items(full_path, "", 1)
header = f"Here're the files and directories up to 2 levels deep in {command.path}, excluding hidden items:"
dir_stat = full_path.stat()
dir_size = _format_file_size(dir_stat.st_size)
lines = [f"{dir_size}\t{command.path}"]
lines.extend([f"{size}\t{command.path}/{path}" for size, path in items])
return f"{header}\n" + "\n".join(lines)
elif full_path.is_file():
content = _read_file_content(full_path, command.path)
lines = content.split("\n")
if len(lines) > MAX_LINES:
raise ToolError(f"File {command.path} exceeds maximum line limit of 999,999 lines.")
display_lines = lines
start_num = 1
if command.view_range and len(command.view_range) == 2:
start_line = max(1, command.view_range[0]) - 1
end_line = len(lines) if command.view_range[1] == -1 else command.view_range[1]
display_lines = lines[start_line:end_line]
start_num = start_line + 1
numbered_lines = [
f"{str(i + start_num).rjust(LINE_NUMBER_WIDTH)}\t{line}" for i, line in enumerate(display_lines)
]
return f"Here's the content of {command.path} with line numbers:\n" + "\n".join(numbered_lines)
else:
raise ToolError(f"Unsupported file type for {command.path}")
@override
def create(self, command: BetaMemoryTool20250818CreateCommand) -> str:
full_path = self._validate_path(command.path)
full_path.parent.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
try:
fd = os.open(full_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, _FILE_CREATE_MODE)
try:
os.write(fd, command.file_text.encode("utf-8"))
os.fsync(fd)
finally:
os.close(fd)
except FileExistsError as err:
raise ToolError(f"File {command.path} already exists") from err
return f"File created successfully at: {command.path}"
@override
def str_replace(self, command: BetaMemoryTool20250818StrReplaceCommand) -> str:
full_path = self._validate_path(command.path)
if not full_path.exists():
raise ToolError(f"The path {command.path} does not exist. Please provide a valid path.")
if not full_path.is_file():
raise ToolError(f"The path {command.path} is not a file.")
content = _read_file_content(full_path, command.path)
count = content.count(command.old_str)
if count == 0:
raise ToolError(
f"No replacement was performed, old_str `{command.old_str}` did not appear verbatim in {command.path}."
)
elif count > 1:
matching_lines: List[int] = []
start = 0
while True:
pos = content.find(command.old_str, start)
if pos == -1:
break
matching_lines.append(content[:pos].count("\n") + 1)
start = pos + 1
raise ToolError(
f"No replacement was performed. Multiple occurrences of old_str `{command.old_str}` in lines: {', '.join(map(str, matching_lines))}. Please ensure it is unique"
)
pos = content.find(command.old_str)
changed_line_index = content[:pos].count("\n")
new_content = content.replace(command.old_str, command.new_str)
_atomic_write_file(full_path, new_content)
new_lines = new_content.split("\n")
context_start = max(0, changed_line_index - 2)
context_end = min(len(new_lines), changed_line_index + 3)
snippet = [
f"{str(line_num).rjust(LINE_NUMBER_WIDTH)}\t{new_lines[line_num - 1]}"
for line_num in range(context_start + 1, context_end + 1)
]
return (
f"The memory file has been edited. Here is the snippet showing the change (with line numbers):\n"
+ "\n".join(snippet)
)
@override
def insert(self, command: BetaMemoryTool20250818InsertCommand) -> str:
full_path = self._validate_path(command.path)
if not full_path.exists():
raise ToolError(f"The path {command.path} does not exist. Please provide a valid path.")
if not full_path.is_file():
raise ToolError(f"The path {command.path} is not a file.")
content = _read_file_content(full_path, command.path)
lines = content.splitlines()
if command.insert_line < 0 or command.insert_line > len(lines):
raise ToolError(
f"Invalid `insert_line` parameter: {command.insert_line}. "
f"It should be within the range [0, {len(lines)}]."
)
lines.insert(command.insert_line, command.insert_text.rstrip("\n"))
new_content = "\n".join(lines)
if not new_content.endswith("\n"):
new_content += "\n"
_atomic_write_file(full_path, content=new_content)
return f"The file {command.path} has been edited."
@override
def delete(self, command: BetaMemoryTool20250818DeleteCommand) -> str:
full_path = self._validate_path(command.path)
if command.path == "/memories":
raise ToolError("Cannot delete the /memories directory itself")
try:
if full_path.is_file():
full_path.unlink()
elif full_path.is_dir():
shutil.rmtree(full_path)
else:
raise ToolError(f"The path {command.path} does not exist")
except FileNotFoundError as err:
raise ToolError(f"The path {command.path} does not exist") from err
return f"Successfully deleted {command.path}"
@override
def rename(self, command: BetaMemoryTool20250818RenameCommand) -> str:
old_full_path = self._validate_path(command.old_path)
new_full_path = self._validate_path(command.new_path)
if new_full_path.exists():
raise ToolError(f"The destination {command.new_path} already exists")
new_full_path.parent.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
try:
old_full_path.rename(new_full_path)
except FileNotFoundError as err:
raise ToolError(f"The path {command.old_path} does not exist") from err
return f"Successfully renamed {command.old_path} to {command.new_path}"
@override
def clear_all_memory(self) -> str:
"""Override the base implementation to provide file system clearing."""
if self.memory_root.exists():
shutil.rmtree(self.memory_root)
self.memory_root.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
return "All memory cleared"
async def _async_atomic_write_file(target_path: AsyncPath, content: str) -> None:
temp_path = target_path.parent / f".tmp-{os.getpid()}-{uuid.uuid4()}"
sync_target_path = Path(str(target_path))
sync_temp_path = Path(str(temp_path))
data = content.encode("utf-8")
try:
def write_replace_and_sync() -> None:
fd = os.open(sync_temp_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, _FILE_CREATE_MODE)
try:
offset = 0
while offset < len(data):
written = os.write(fd, data[offset:])
if written == 0:
raise OSError("os.write returned 0")
offset += written
os.fsync(fd)
finally:
os.close(fd)
os.replace(sync_temp_path, sync_target_path)
await run_sync(write_replace_and_sync)
except Exception:
await temp_path.unlink(missing_ok=True)
raise
async def _async_validate_no_symlink_escape(target_path: AsyncPath, memory_root: AsyncPath) -> None:
sync_target = Path(str(target_path))
sync_root = Path(str(memory_root))
await run_sync(_validate_no_symlink_escape, sync_target, sync_root)
async def _async_read_file_content(full_path: AsyncPath, memory_path: str) -> str:
try:
return await full_path.read_text(encoding="utf-8")
except FileNotFoundError as err:
raise ToolError(
f"The file {memory_path} no longer exists (may have been deleted or renamed concurrently)."
) from err
class BetaAsyncLocalFilesystemMemoryTool(BetaAsyncAbstractMemoryTool):
"""Async file-based memory storage implementation for Claude conversations"""
def __init__(self, base_path: str = "./memory"):
super().__init__()
self.base_path = AsyncPath(base_path)
self.memory_root = self.base_path / "memories"
# Note: Directory creation is deferred to async methods since __init__ can't be async
async def _ensure_memory_root(self) -> None:
"""Ensure the memory root directory exists"""
await self.memory_root.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
async def _validate_path(self, path: str) -> AsyncPath:
"""Validate and resolve memory paths"""
if not path.startswith("/memories"):
raise ToolError(f"Path must start with /memories, got: {path}")
relative_path = path[len("/memories") :].lstrip("/")
full_path = self.memory_root / relative_path if relative_path else self.memory_root
sync_memory_root = Path(str(self.memory_root))
sync_full_path = Path(str(full_path))
resolved_path = sync_full_path.resolve()
resolved_root = sync_memory_root.resolve()
if resolved_path != resolved_root and not str(resolved_path).startswith(str(resolved_root) + os.sep):
raise ToolError(f"Path {path} would escape /memories directory")
await _async_validate_no_symlink_escape(full_path, self.memory_root)
return AsyncPath(resolved_path)
@override
async def view(self, command: BetaMemoryTool20250818ViewCommand) -> str:
await self._ensure_memory_root()
full_path = await self._validate_path(command.path)
if not await full_path.exists():
raise ToolError(f"The path {command.path} does not exist. Please provide a valid path.")
if await full_path.is_dir():
items: List[tuple[str, str]] = []
async def collect_items(dir_path: AsyncPath, relative_path: str, depth: int) -> None:
if depth > 2:
return
try:
dir_items = [item async for item in dir_path.iterdir()]
dir_contents = sorted(dir_items, key=lambda x: x.name)
except Exception:
return
for item in dir_contents:
if item.name.startswith("."):
continue
item_relative_path = f"{relative_path}/{item.name}" if relative_path else item.name
try:
sync_item = Path(str(item))
stat = await run_sync(sync_item.stat)
except Exception:
continue
if await item.is_dir():
items.append((_format_file_size(stat.st_size), f"{item_relative_path}/"))
if depth < 2:
await collect_items(item, item_relative_path, depth + 1)
elif await item.is_file():
items.append((_format_file_size(stat.st_size), item_relative_path))
await collect_items(full_path, "", 1)
header = f"Here're the files and directories up to 2 levels deep in {command.path}, excluding hidden items:"
sync_full_path = Path(str(full_path))
dir_stat = await run_sync(sync_full_path.stat)
dir_size = _format_file_size(dir_stat.st_size)
lines = [f"{dir_size}\t{command.path}"]
lines.extend([f"{size}\t{command.path}/{path}" for size, path in items])
return f"{header}\n" + "\n".join(lines)
elif await full_path.is_file():
content = await _async_read_file_content(full_path, command.path)
lines = content.split("\n")
if len(lines) > MAX_LINES:
raise ToolError(f"File {command.path} exceeds maximum line limit of 999,999 lines.")
display_lines = lines
start_num = 1
if command.view_range and len(command.view_range) == 2:
start_line = max(1, command.view_range[0]) - 1
end_line = len(lines) if command.view_range[1] == -1 else command.view_range[1]
display_lines = lines[start_line:end_line]
start_num = start_line + 1
numbered_lines = [
f"{str(i + start_num).rjust(LINE_NUMBER_WIDTH)}\t{line}" for i, line in enumerate(display_lines)
]
return f"Here's the content of {command.path} with line numbers:\n" + "\n".join(numbered_lines)
else:
raise ToolError(f"Unsupported file type for {command.path}")
@override
async def create(self, command: BetaMemoryTool20250818CreateCommand) -> str:
await self._ensure_memory_root()
full_path = await self._validate_path(command.path)
await full_path.parent.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
try:
sync_full_path = Path(str(full_path))
def create_exclusive() -> None:
fd = os.open(sync_full_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, _FILE_CREATE_MODE)
try:
os.write(fd, command.file_text.encode("utf-8"))
os.fsync(fd)
finally:
os.close(fd)
await run_sync(create_exclusive)
except FileExistsError as err:
raise ToolError(f"File {command.path} already exists") from err
return f"File created successfully at: {command.path}"
@override
async def str_replace(self, command: BetaMemoryTool20250818StrReplaceCommand) -> str:
await self._ensure_memory_root()
full_path = await self._validate_path(command.path)
if not await full_path.exists():
raise ToolError(f"The path {command.path} does not exist. Please provide a valid path.")
if not await full_path.is_file():
raise ToolError(f"The path {command.path} is not a file.")
content = await _async_read_file_content(full_path, command.path)
count = content.count(command.old_str)
if count == 0:
raise ToolError(
f"No replacement was performed, old_str `{command.old_str}` did not appear verbatim in {command.path}."
)
elif count > 1:
matching_lines: List[int] = []
start = 0
while True:
pos = content.find(command.old_str, start)
if pos == -1:
break
matching_lines.append(content[:pos].count("\n") + 1)
start = pos + 1
raise ToolError(
f"No replacement was performed. Multiple occurrences of old_str `{command.old_str}` in lines: {', '.join(map(str, matching_lines))}. Please ensure it is unique"
)
pos = content.find(command.old_str)
changed_line_index = content[:pos].count("\n")
new_content = content.replace(command.old_str, command.new_str)
await _async_atomic_write_file(full_path, new_content)
new_lines = new_content.split("\n")
context_start = max(0, changed_line_index - 2)
context_end = min(len(new_lines), changed_line_index + 3)
snippet = [
f"{str(line_num).rjust(LINE_NUMBER_WIDTH)}\t{new_lines[line_num - 1]}"
for line_num in range(context_start + 1, context_end + 1)
]
return (
f"The memory file has been edited. Here is the snippet showing the change (with line numbers):\n"
+ "\n".join(snippet)
)
@override
async def insert(self, command: BetaMemoryTool20250818InsertCommand) -> str:
await self._ensure_memory_root()
full_path = await self._validate_path(command.path)
if not await full_path.exists():
raise ToolError(f"The path {command.path} does not exist. Please provide a valid path.")
if not await full_path.is_file():
raise ToolError(f"The path {command.path} is not a file.")
content = await _async_read_file_content(full_path, command.path)
lines = content.splitlines()
if command.insert_line < 0 or command.insert_line > len(lines):
raise ToolError(
f"Invalid `insert_line` parameter: {command.insert_line}. "
f"It should be within the range [0, {len(lines)}]."
)
lines.insert(command.insert_line, command.insert_text.rstrip("\n"))
new_content = "\n".join(lines)
if not new_content.endswith("\n"):
new_content += "\n"
await _async_atomic_write_file(full_path, content=new_content)
return f"The file {command.path} has been edited."
@override
async def delete(self, command: BetaMemoryTool20250818DeleteCommand) -> str:
await self._ensure_memory_root()
full_path = await self._validate_path(command.path)
if command.path == "/memories":
raise ToolError("Cannot delete the /memories directory itself")
try:
if await full_path.is_file():
await full_path.unlink()
elif await full_path.is_dir():
await run_sync(shutil.rmtree, str(full_path))
else:
raise ToolError(f"The path {command.path} does not exist")
except FileNotFoundError as err:
raise ToolError(f"The path {command.path} does not exist") from err
return f"Successfully deleted {command.path}"
@override
async def rename(self, command: BetaMemoryTool20250818RenameCommand) -> str:
await self._ensure_memory_root()
old_full_path = await self._validate_path(command.old_path)
new_full_path = await self._validate_path(command.new_path)
if await new_full_path.exists():
raise ToolError(f"The destination {command.new_path} already exists")
await new_full_path.parent.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
try:
await old_full_path.rename(new_full_path)
except FileNotFoundError as err:
raise ToolError(f"The path {command.old_path} does not exist") from err
return f"Successfully renamed {command.old_path} to {command.new_path}"
@override
async def clear_all_memory(self) -> str:
"""Override the base implementation to provide file system clearing."""
if await self.memory_root.exists():
await run_sync(shutil.rmtree, str(self.memory_root))
await self.memory_root.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
return "All memory cleared"

View File

@@ -0,0 +1,55 @@
from typing import TypedDict
from typing_extensions import Required
DEFAULT_SUMMARY_PROMPT = """You have been working on the task described above but have not yet completed it. Write a continuation summary that will allow you (or another instance of yourself) to resume work efficiently in a future context window where the conversation history will be replaced with this summary. Your summary should be structured, concise, and actionable. Include:
1. Task Overview
The user's core request and success criteria
Any clarifications or constraints they specified
2. Current State
What has been completed so far
Files created, modified, or analyzed (with paths if relevant)
Key outputs or artifacts produced
3. Important Discoveries
Technical constraints or requirements uncovered
Decisions made and their rationale
Errors encountered and how they were resolved
What approaches were tried that didn't work (and why)
4. Next Steps
Specific actions needed to complete the task
Any blockers or open questions to resolve
Priority order if multiple steps remain
5. Context to Preserve
User preferences or style requirements
Domain-specific details that aren't obvious
Any promises made to the user
Be concise but complete—err on the side of including information that would prevent duplicate work or repeated mistakes. Write in a way that enables immediate resumption of the task.
Wrap your summary in <summary></summary> tags."""
DEFAULT_THRESHOLD = 100_000
class CompactionControl(TypedDict, total=False):
"""Client-side compaction control configuration.
.. deprecated::
Use server-side compaction instead by passing
``edits=[{"type": "compact_20260112"}]`` in the params passed to ``tool_runner()``.
See https://platform.claude.com/docs/en/build-with-claude/compaction
"""
context_token_threshold: int
"""The context token threshold at which to trigger compaction.
When the cumulative token count (input + output) across all messages exceeds this threshold,
the message history will be automatically summarized and compressed. Defaults to 100,000 tokens.
"""
model: str
"""
The model to use for generating the compaction summary.
If not specified, defaults to the same model used for the tool runner.
"""
summary_prompt: str
"""The prompt used to instruct the model on how to generate the summary."""
enabled: Required[bool]

View File

@@ -0,0 +1,429 @@
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from typing import Any, Union, Generic, TypeVar, Callable, Iterable, Coroutine, cast, overload
from inspect import iscoroutinefunction
from typing_extensions import Literal, TypeAlias, override
import pydantic
import docstring_parser
from pydantic import BaseModel
from ... import _compat
from ..._utils import is_dict
from ..._compat import cached_property
from ..._models import TypeAdapter
from ...types.beta import BetaToolParam, BetaToolUnionParam, BetaCacheControlEphemeralParam
from ..._utils._utils import CallableT
from ...types.tool_param import InputSchema
from ...types.beta.beta_tool_result_block_param import Content as BetaContent
log = logging.getLogger(__name__)
BetaFunctionToolResultType: TypeAlias = Union[str, Iterable[BetaContent]]
class ToolError(Exception):
"""Error that can be raised from a tool to return structured content with ``is_error: True``.
When the tool runner catches this error, it will use the :attr:`content`
property as the tool result instead of ``repr(exc)``.
Example::
raise ToolError(
[
{"type": "text", "text": "Error details here"},
{"type": "image", "source": {"type": "base64", "data": "...", "media_type": "image/png"}},
]
)
"""
content: BetaFunctionToolResultType
def __init__(self, content: BetaFunctionToolResultType) -> None:
if isinstance(content, str):
message = content
else:
parts: list[str] = []
for block in content:
text = block.get("text")
if text is not None:
parts.append(str(text))
else:
parts.append(f"[{block.get('type', 'unknown')}]")
message = " ".join(parts) if parts else "Tool error"
super().__init__(message)
self.content = content
Function = Callable[..., BetaFunctionToolResultType]
FunctionT = TypeVar("FunctionT", bound=Function)
AsyncFunction = Callable[..., Coroutine[Any, Any, BetaFunctionToolResultType]]
AsyncFunctionT = TypeVar("AsyncFunctionT", bound=AsyncFunction)
class BetaBuiltinFunctionTool(ABC):
@abstractmethod
def to_dict(self) -> BetaToolUnionParam: ...
@abstractmethod
def call(self, input: object) -> BetaFunctionToolResultType: ...
@property
def name(self) -> str:
raw = self.to_dict()
if "mcp_server_name" in raw:
return raw["mcp_server_name"]
return raw["name"]
class BetaAsyncBuiltinFunctionTool(ABC):
@abstractmethod
def to_dict(self) -> BetaToolUnionParam: ...
@abstractmethod
async def call(self, input: object) -> BetaFunctionToolResultType: ...
@property
def name(self) -> str:
raw = self.to_dict()
if "mcp_server_name" in raw:
return raw["mcp_server_name"]
return raw["name"]
class BaseFunctionTool(Generic[CallableT]):
func: CallableT
"""The function this tool is wrapping"""
name: str
"""The name of the tool that will be sent to the API"""
description: str
input_schema: InputSchema
def __init__(
self,
func: CallableT,
*,
name: str | None = None,
description: str | None = None,
input_schema: InputSchema | type[BaseModel] | None = None,
defer_loading: bool | None = None,
cache_control: BetaCacheControlEphemeralParam | None = None,
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
eager_input_streaming: bool | None = None,
input_examples: Iterable[dict[str, object]] | None = None,
strict: bool | None = None,
) -> None:
if _compat.PYDANTIC_V1:
raise RuntimeError("Tool functions are only supported with Pydantic v2")
self.func = func
self._func_with_validate = pydantic.validate_call(func)
self.name = name or func.__name__
self._defer_loading = defer_loading
self._cache_control = cache_control
self._allowed_callers = allowed_callers
self._eager_input_streaming = eager_input_streaming
self._input_examples = input_examples
self._strict = strict
self.description = description or self._get_description_from_docstring()
if input_schema is not None:
if isinstance(input_schema, type):
self.input_schema: InputSchema = input_schema.model_json_schema()
else:
self.input_schema = input_schema
else:
self.input_schema = self._create_schema_from_function()
@property
def __call__(self) -> CallableT:
return self.func
def to_dict(self) -> BetaToolParam:
defn: BetaToolParam = {
"name": self.name,
"description": self.description,
"input_schema": self.input_schema,
}
if self._defer_loading is not None:
defn["defer_loading"] = self._defer_loading
if self._cache_control is not None:
defn["cache_control"] = self._cache_control
if self._allowed_callers is not None:
defn["allowed_callers"] = self._allowed_callers
if self._eager_input_streaming is not None:
defn["eager_input_streaming"] = self._eager_input_streaming
if self._input_examples is not None:
defn["input_examples"] = self._input_examples
if self._strict is not None:
defn["strict"] = self._strict
return defn
@cached_property
def _parsed_docstring(self) -> docstring_parser.Docstring:
return docstring_parser.parse(self.func.__doc__ or "")
def _get_description_from_docstring(self) -> str:
"""Extract description from parsed docstring."""
if self._parsed_docstring.short_description:
description = self._parsed_docstring.short_description
if self._parsed_docstring.long_description:
description += f"\n\n{self._parsed_docstring.long_description}"
return description
return ""
def _create_schema_from_function(self) -> InputSchema:
"""Create JSON schema from function signature using pydantic."""
from pydantic_core import CoreSchema
from pydantic.json_schema import JsonSchemaValue, GenerateJsonSchema
from pydantic_core.core_schema import ArgumentsParameter
class CustomGenerateJsonSchema(GenerateJsonSchema):
def __init__(self, *, func: Callable[..., Any], parsed_docstring: Any) -> None:
super().__init__()
self._func = func
self._parsed_docstring = parsed_docstring
def __call__(self, *_args: Any, **_kwds: Any) -> "CustomGenerateJsonSchema": # noqa: ARG002
return self
@override
def kw_arguments_schema(
self,
arguments: "list[ArgumentsParameter]",
var_kwargs_schema: CoreSchema | None,
) -> JsonSchemaValue:
schema = super().kw_arguments_schema(arguments, var_kwargs_schema)
if schema.get("type") != "object":
return schema
properties = schema.get("properties")
if not properties or not is_dict(properties):
return schema
# Add parameter descriptions from docstring
for param in self._parsed_docstring.params:
prop_schema = properties.get(param.arg_name)
if not prop_schema or not is_dict(prop_schema):
continue
if param.description and "description" not in prop_schema:
prop_schema["description"] = param.description
return schema
schema_generator = CustomGenerateJsonSchema(func=self.func, parsed_docstring=self._parsed_docstring)
return self._adapter.json_schema(schema_generator=schema_generator) # type: ignore
@cached_property
def _adapter(self) -> TypeAdapter[Any]:
return TypeAdapter(self._func_with_validate)
class BetaFunctionTool(BaseFunctionTool[FunctionT]):
def call(self, input: object) -> BetaFunctionToolResultType:
if iscoroutinefunction(self.func):
raise RuntimeError("Cannot call a coroutine function synchronously. Use `@async_tool` instead.")
if not is_dict(input):
raise TypeError(f"Input must be a dictionary, got {type(input).__name__}")
try:
return self._func_with_validate(**cast(Any, input))
except pydantic.ValidationError as e:
raise ValueError(f"Invalid arguments for function {self.name}") from e
class BetaAsyncFunctionTool(BaseFunctionTool[AsyncFunctionT]):
async def call(self, input: object) -> BetaFunctionToolResultType:
if not iscoroutinefunction(self.func):
raise RuntimeError("Cannot call a synchronous function asynchronously. Use `@tool` instead.")
if not is_dict(input):
raise TypeError(f"Input must be a dictionary, got {type(input).__name__}")
try:
return await self._func_with_validate(**cast(Any, input))
except pydantic.ValidationError as e:
raise ValueError(f"Invalid arguments for function {self.name}") from e
@overload
def beta_tool(func: FunctionT) -> BetaFunctionTool[FunctionT]: ...
@overload
def beta_tool(
func: FunctionT,
*,
name: str | None = None,
description: str | None = None,
input_schema: InputSchema | type[BaseModel] | None = None,
defer_loading: bool | None = None,
cache_control: BetaCacheControlEphemeralParam | None = None,
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
eager_input_streaming: bool | None = None,
input_examples: Iterable[dict[str, object]] | None = None,
strict: bool | None = None,
) -> BetaFunctionTool[FunctionT]: ...
@overload
def beta_tool(
*,
name: str | None = None,
description: str | None = None,
input_schema: InputSchema | type[BaseModel] | None = None,
defer_loading: bool | None = None,
cache_control: BetaCacheControlEphemeralParam | None = None,
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
eager_input_streaming: bool | None = None,
input_examples: Iterable[dict[str, object]] | None = None,
strict: bool | None = None,
) -> Callable[[FunctionT], BetaFunctionTool[FunctionT]]: ...
def beta_tool(
func: FunctionT | None = None,
*,
name: str | None = None,
description: str | None = None,
input_schema: InputSchema | type[BaseModel] | None = None,
defer_loading: bool | None = None,
cache_control: BetaCacheControlEphemeralParam | None = None,
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
eager_input_streaming: bool | None = None,
input_examples: Iterable[dict[str, object]] | None = None,
strict: bool | None = None,
) -> BetaFunctionTool[FunctionT] | Callable[[FunctionT], BetaFunctionTool[FunctionT]]:
"""Create a FunctionTool from a function with automatic schema inference.
Can be used as a decorator with or without parentheses:
@function_tool
def my_func(x: int) -> str: ...
@function_tool()
def my_func(x: int) -> str: ...
@function_tool(name="custom_name")
def my_func(x: int) -> str: ...
"""
if _compat.PYDANTIC_V1:
raise RuntimeError("Tool functions are only supported with Pydantic v2")
def _make(fn: FunctionT) -> BetaFunctionTool[FunctionT]:
return BetaFunctionTool(
fn,
name=name,
description=description,
input_schema=input_schema,
defer_loading=defer_loading,
cache_control=cache_control,
allowed_callers=allowed_callers,
eager_input_streaming=eager_input_streaming,
input_examples=input_examples,
strict=strict,
)
if func is not None:
return _make(func)
return _make
@overload
def beta_async_tool(func: AsyncFunctionT) -> BetaAsyncFunctionTool[AsyncFunctionT]: ...
@overload
def beta_async_tool(
func: AsyncFunctionT,
*,
name: str | None = None,
description: str | None = None,
input_schema: InputSchema | type[BaseModel] | None = None,
defer_loading: bool | None = None,
cache_control: BetaCacheControlEphemeralParam | None = None,
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
eager_input_streaming: bool | None = None,
input_examples: Iterable[dict[str, object]] | None = None,
strict: bool | None = None,
) -> BetaAsyncFunctionTool[AsyncFunctionT]: ... # noqa: E501
@overload
def beta_async_tool(
*,
name: str | None = None,
description: str | None = None,
input_schema: InputSchema | type[BaseModel] | None = None,
defer_loading: bool | None = None,
cache_control: BetaCacheControlEphemeralParam | None = None,
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
eager_input_streaming: bool | None = None,
input_examples: Iterable[dict[str, object]] | None = None,
strict: bool | None = None,
) -> Callable[[AsyncFunctionT], BetaAsyncFunctionTool[AsyncFunctionT]]: ...
def beta_async_tool(
func: AsyncFunctionT | None = None,
*,
name: str | None = None,
description: str | None = None,
input_schema: InputSchema | type[BaseModel] | None = None,
defer_loading: bool | None = None,
cache_control: BetaCacheControlEphemeralParam | None = None,
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
eager_input_streaming: bool | None = None,
input_examples: Iterable[dict[str, object]] | None = None,
strict: bool | None = None,
) -> BetaAsyncFunctionTool[AsyncFunctionT] | Callable[[AsyncFunctionT], BetaAsyncFunctionTool[AsyncFunctionT]]:
"""Create an AsyncFunctionTool from a function with automatic schema inference.
Can be used as a decorator with or without parentheses:
@async_tool
async def my_func(x: int) -> str: ...
@async_tool()
async def my_func(x: int) -> str: ...
@async_tool(name="custom_name")
async def my_func(x: int) -> str: ...
"""
if _compat.PYDANTIC_V1:
raise RuntimeError("Tool functions are only supported with Pydantic v2")
def _make(fn: AsyncFunctionT) -> BetaAsyncFunctionTool[AsyncFunctionT]:
return BetaAsyncFunctionTool(
fn,
name=name,
description=description,
input_schema=input_schema,
defer_loading=defer_loading,
cache_control=cache_control,
allowed_callers=allowed_callers,
eager_input_streaming=eager_input_streaming,
input_examples=input_examples,
strict=strict,
)
if func is not None:
return _make(func)
return _make
BetaRunnableTool = Union[BetaFunctionTool[Any], BetaBuiltinFunctionTool]
BetaAsyncRunnableTool = Union[BetaAsyncFunctionTool[Any], BetaAsyncBuiltinFunctionTool]

View File

@@ -0,0 +1,685 @@
from __future__ import annotations
import logging
import warnings
from abc import ABC, abstractmethod
from typing import (
TYPE_CHECKING,
Any,
List,
Union,
Generic,
TypeVar,
Callable,
Iterable,
Iterator,
Coroutine,
AsyncIterator,
)
from contextlib import contextmanager, asynccontextmanager
from typing_extensions import TypedDict, override
import httpx
from ..._types import Body, Query, Headers, NotGiven
from ..._utils import consume_sync_iterator, consume_async_iterator
from ...types.beta import BetaMessage, BetaMessageParam
from ._beta_functions import (
ToolError,
BetaFunctionTool,
BetaRunnableTool,
BetaAsyncFunctionTool,
BetaAsyncRunnableTool,
BetaBuiltinFunctionTool,
BetaAsyncBuiltinFunctionTool,
)
from .._stainless_helpers import stainless_helper_header
from ._beta_compaction_control import DEFAULT_THRESHOLD, DEFAULT_SUMMARY_PROMPT, CompactionControl
from ..streaming._beta_messages import BetaMessageStream, BetaAsyncMessageStream
from ...types.beta.parsed_beta_message import ResponseFormatT, ParsedBetaMessage, ParsedBetaContentBlock
from ...types.beta.message_create_params import ParseMessageCreateParamsBase
from ...types.beta.beta_tool_result_block_param import BetaToolResultBlockParam
if TYPE_CHECKING:
from ..._client import Anthropic, AsyncAnthropic
AnyFunctionToolT = TypeVar(
"AnyFunctionToolT",
bound=Union[
BetaFunctionTool[Any], BetaAsyncFunctionTool[Any], BetaBuiltinFunctionTool, BetaAsyncBuiltinFunctionTool
],
)
RunnerItemT = TypeVar("RunnerItemT")
log = logging.getLogger(__name__)
class RequestOptions(TypedDict, total=False):
extra_headers: Headers | None
extra_query: Query | None
extra_body: Body | None
timeout: float | httpx.Timeout | None | NotGiven
class BaseToolRunner(Generic[AnyFunctionToolT, ResponseFormatT]):
def __init__(
self,
*,
params: ParseMessageCreateParamsBase[ResponseFormatT],
options: RequestOptions,
tools: Iterable[AnyFunctionToolT],
max_iterations: int | None = None,
compaction_control: CompactionControl | None = None,
) -> None:
self._tools_by_name = {tool.name: tool for tool in tools}
self._params: ParseMessageCreateParamsBase[ResponseFormatT] = {
**params,
"messages": [message for message in params["messages"]],
}
helper_header = stainless_helper_header(
tools=self._tools_by_name.values(),
messages=params.get("messages"),
)
if helper_header:
merged_headers = {**helper_header, **(options.get("extra_headers") or {})}
options = {**options, "extra_headers": merged_headers}
self._options = options
self._messages_modified = False
self._cached_tool_call_response: BetaMessageParam | None = None
self._max_iterations = max_iterations
self._iteration_count = 0
self._compaction_control = compaction_control
def set_messages_params(
self,
params: ParseMessageCreateParamsBase[ResponseFormatT]
| Callable[[ParseMessageCreateParamsBase[ResponseFormatT]], ParseMessageCreateParamsBase[ResponseFormatT]],
) -> None:
"""
Update the parameters for the next API call. This invalidates any cached tool responses.
Args:
params (ParsedMessageCreateParamsBase[ResponseFormatT] | Callable): Either new parameters or a function to mutate existing parameters
"""
if callable(params):
params = params(self._params)
self._params = params
def append_messages(self, *messages: BetaMessageParam | ParsedBetaMessage[ResponseFormatT]) -> None:
"""Add one or more messages to the conversation history.
This invalidates the cached tool response, i.e. if tools were already called, then they will
be called again on the next loop iteration.
"""
message_params: List[BetaMessageParam] = [
{"role": message.role, "content": message.content} if isinstance(message, BetaMessage) else message
for message in messages
]
self._messages_modified = True
self.set_messages_params(lambda params: {**params, "messages": [*params["messages"], *message_params]})
self._cached_tool_call_response = None
def _should_stop(self) -> bool:
if self._max_iterations is not None and self._iteration_count >= self._max_iterations:
return True
return False
class BaseSyncToolRunner(BaseToolRunner[BetaRunnableTool, ResponseFormatT], Generic[RunnerItemT, ResponseFormatT], ABC):
def __init__(
self,
*,
params: ParseMessageCreateParamsBase[ResponseFormatT],
options: RequestOptions,
tools: Iterable[BetaRunnableTool],
client: Anthropic,
max_iterations: int | None = None,
compaction_control: CompactionControl | None = None,
) -> None:
super().__init__(
params=params,
options=options,
tools=tools,
max_iterations=max_iterations,
compaction_control=compaction_control,
)
self._client = client
if compaction_control is not None and compaction_control.get("enabled"):
warnings.warn(
"The 'compaction_control' parameter is deprecated and will be removed in a future version. "
"Use server-side compaction instead by passing `edits=[{'type': 'compact_20260112'}]` in your "
"the params passed to `tool_runner()`. See https://platform.claude.com/docs/en/build-with-claude/compaction",
DeprecationWarning,
stacklevel=3,
)
self._iterator = self.__run__()
self._last_message: (
Callable[[], ParsedBetaMessage[ResponseFormatT]] | ParsedBetaMessage[ResponseFormatT] | None
) = None
def __next__(self) -> RunnerItemT:
return self._iterator.__next__()
def __iter__(self) -> Iterator[RunnerItemT]:
for item in self._iterator:
yield item
@abstractmethod
@contextmanager
def _handle_request(self) -> Iterator[RunnerItemT]:
raise NotImplementedError()
yield # type: ignore[unreachable]
def _check_and_compact(self) -> bool:
"""
Check token usage and compact messages if threshold exceeded.
Returns True if compaction was performed, False otherwise.
"""
if self._compaction_control is None or not self._compaction_control["enabled"]:
return False
message = self._get_last_message()
tokens_used = 0
if message is not None:
total_input_tokens = (
message.usage.input_tokens
+ (message.usage.cache_creation_input_tokens or 0)
+ (message.usage.cache_read_input_tokens or 0)
)
tokens_used = total_input_tokens + message.usage.output_tokens
threshold = self._compaction_control.get("context_token_threshold", DEFAULT_THRESHOLD)
if tokens_used < threshold:
return False
# Perform compaction
log.info(f"Token usage {tokens_used} has exceeded the threshold of {threshold}. Performing compaction.")
model = self._compaction_control.get("model", self._params["model"])
messages = list(self._params["messages"])
if messages[-1]["role"] == "assistant":
# Remove tool_use blocks from the last message to avoid 400 error
# (tool_use requires tool_result, which we don't have yet)
non_tool_blocks = [
block
for block in messages[-1]["content"]
if isinstance(block, dict) and block.get("type") != "tool_use"
]
if non_tool_blocks:
messages[-1]["content"] = non_tool_blocks
else:
messages.pop()
messages = [
*messages,
BetaMessageParam(
role="user",
content=self._compaction_control.get("summary_prompt", DEFAULT_SUMMARY_PROMPT),
),
]
response = self._client.beta.messages.create(
model=model,
messages=messages,
max_tokens=self._params["max_tokens"],
extra_headers={"X-Stainless-Helper": "compaction"},
)
log.info(f"Compaction complete. New token usage: {response.usage.output_tokens}")
first_content = list(response.content)[0]
if first_content.type != "text":
raise ValueError("Compaction response content is not of type 'text'")
self.set_messages_params(
lambda params: {
**params,
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": first_content.text,
}
],
}
],
}
)
return True
def __run__(self) -> Iterator[RunnerItemT]:
while not self._should_stop():
with self._handle_request() as item:
yield item
message = self._get_last_message()
assert message is not None
# Update container from response for programmatic tool calling support
last_assistant_message = self._get_last_assistant_message()
if last_assistant_message is not None and last_assistant_message.container is not None:
self._params["container"] = last_assistant_message.container.id
self._iteration_count += 1
# If the compaction was performed, skip tool call generation this iteration
if not self._check_and_compact():
response = self.generate_tool_call_response()
if response is None:
log.debug("Tool call was not requested, exiting from tool runner loop.")
return
if not self._messages_modified:
self.append_messages(message, response)
self._messages_modified = False
self._cached_tool_call_response = None
def until_done(self) -> ParsedBetaMessage[ResponseFormatT]:
"""
Consumes the tool runner stream and returns the last message if it has not been consumed yet.
If it has, it simply returns the last message.
"""
consume_sync_iterator(self)
last_message = self._get_last_message()
assert last_message is not None
return last_message
def generate_tool_call_response(self) -> BetaMessageParam | None:
"""Generate a MessageParam by calling tool functions with any tool use blocks from the last message.
Note the tool call response is cached, repeated calls to this method will return the same response.
None can be returned if no tool call was applicable.
"""
if self._cached_tool_call_response is not None:
log.debug("Returning cached tool call response.")
return self._cached_tool_call_response
response = self._generate_tool_call_response()
self._cached_tool_call_response = response
return response
def _generate_tool_call_response(self) -> BetaMessageParam | None:
content = self._get_last_assistant_message_content()
if not content:
return None
tool_use_blocks = [block for block in content if block.type == "tool_use"]
if not tool_use_blocks:
return None
results: list[BetaToolResultBlockParam] = []
for tool_use in tool_use_blocks:
tool = self._tools_by_name.get(tool_use.name)
if tool is None:
warnings.warn(
f"Tool '{tool_use.name}' not found in tool runner. "
f"Available tools: {list(self._tools_by_name.keys())}. "
f"If using a raw tool definition, handle the tool call manually and use `append_messages()` to add the result. "
f"Otherwise, pass the tool using `beta_tool(func)` or a `@beta_tool` decorated function.",
UserWarning,
stacklevel=3,
)
results.append(
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": f"Error: Tool '{tool_use.name}' not found",
"is_error": True,
}
)
continue
try:
result = tool.call(tool_use.input)
results.append({"type": "tool_result", "tool_use_id": tool_use.id, "content": result})
except ToolError as exc:
results.append(
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": exc.content,
"is_error": True,
}
)
except Exception as exc:
log.exception(f"Error occurred while calling tool: {tool.name}", exc_info=exc)
results.append(
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": repr(exc),
"is_error": True,
}
)
return {"role": "user", "content": results}
def _get_last_message(self) -> ParsedBetaMessage[ResponseFormatT] | None:
if callable(self._last_message):
return self._last_message()
return self._last_message
def _get_last_assistant_message(self) -> ParsedBetaMessage[ResponseFormatT] | None:
last_message = self._get_last_message()
if last_message is None or last_message.role != "assistant" or not last_message.content:
return None
return last_message
def _get_last_assistant_message_content(self) -> list[ParsedBetaContentBlock[ResponseFormatT]] | None:
last_assistant_message = self._get_last_assistant_message()
if last_assistant_message is None:
return None
return last_assistant_message.content
class BetaToolRunner(BaseSyncToolRunner[ParsedBetaMessage[ResponseFormatT], ResponseFormatT]):
@override
@contextmanager
def _handle_request(self) -> Iterator[ParsedBetaMessage[ResponseFormatT]]:
message = self._client.beta.messages.parse(**self._params, **self._options)
self._last_message = message
yield message
class BetaStreamingToolRunner(BaseSyncToolRunner[BetaMessageStream[ResponseFormatT], ResponseFormatT]):
@override
@contextmanager
def _handle_request(self) -> Iterator[BetaMessageStream[ResponseFormatT]]:
with self._client.beta.messages.stream(**self._params, **self._options) as stream:
self._last_message = stream.get_final_message
yield stream
class BaseAsyncToolRunner(
BaseToolRunner[BetaAsyncRunnableTool, ResponseFormatT], Generic[RunnerItemT, ResponseFormatT], ABC
):
def __init__(
self,
*,
params: ParseMessageCreateParamsBase[ResponseFormatT],
options: RequestOptions,
tools: Iterable[BetaAsyncRunnableTool],
client: AsyncAnthropic,
max_iterations: int | None = None,
compaction_control: CompactionControl | None = None,
) -> None:
super().__init__(
params=params,
options=options,
tools=tools,
max_iterations=max_iterations,
compaction_control=compaction_control,
)
self._client = client
if compaction_control is not None and compaction_control.get("enabled"):
warnings.warn(
"The 'compaction_control' parameter is deprecated and will be removed in a future version. "
"Use server-side compaction instead by passing `edits=[{'type': 'compact_20260112'}]` in your "
"the params passed to `tool_runner()`. See https://platform.claude.com/docs/en/build-with-claude/compaction",
DeprecationWarning,
stacklevel=3,
)
self._iterator = self.__run__()
self._last_message: (
Callable[[], Coroutine[None, None, ParsedBetaMessage[ResponseFormatT]]]
| ParsedBetaMessage[ResponseFormatT]
| None
) = None
async def __anext__(self) -> RunnerItemT:
return await self._iterator.__anext__()
async def __aiter__(self) -> AsyncIterator[RunnerItemT]:
async for item in self._iterator:
yield item
@abstractmethod
@asynccontextmanager
async def _handle_request(self) -> AsyncIterator[RunnerItemT]:
raise NotImplementedError()
yield # type: ignore[unreachable]
async def _check_and_compact(self) -> bool:
"""
Check token usage and compact messages if threshold exceeded.
Returns True if compaction was performed, False otherwise.
"""
if self._compaction_control is None or not self._compaction_control["enabled"]:
return False
message = await self._get_last_message()
tokens_used = 0
if message is not None:
total_input_tokens = (
message.usage.input_tokens
+ (message.usage.cache_creation_input_tokens or 0)
+ (message.usage.cache_read_input_tokens or 0)
)
tokens_used = total_input_tokens + message.usage.output_tokens
threshold = self._compaction_control.get("context_token_threshold", DEFAULT_THRESHOLD)
if tokens_used < threshold:
return False
# Perform compaction
log.info(f"Token usage {tokens_used} has exceeded the threshold of {threshold}. Performing compaction.")
model = self._compaction_control.get("model", self._params["model"])
messages = list(self._params["messages"])
if messages[-1]["role"] == "assistant":
# Remove tool_use blocks from the last message to avoid 400 error
# (tool_use requires tool_result, which we don't have yet)
non_tool_blocks = [
block
for block in messages[-1]["content"]
if isinstance(block, dict) and block.get("type") != "tool_use"
]
if non_tool_blocks:
messages[-1]["content"] = non_tool_blocks
else:
messages.pop()
messages = [
*messages,
BetaMessageParam(
role="user",
content=self._compaction_control.get("summary_prompt", DEFAULT_SUMMARY_PROMPT),
),
]
response = await self._client.beta.messages.create(
model=model,
messages=messages,
max_tokens=self._params["max_tokens"],
extra_headers={"X-Stainless-Helper": "compaction"},
)
log.info(f"Compaction complete. New token usage: {response.usage.output_tokens}")
first_content = list(response.content)[0]
if first_content.type != "text":
raise ValueError("Compaction response content is not of type 'text'")
self.set_messages_params(
lambda params: {
**params,
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": first_content.text,
}
],
}
],
}
)
return True
async def __run__(self) -> AsyncIterator[RunnerItemT]:
while not self._should_stop():
async with self._handle_request() as item:
yield item
message = await self._get_last_message()
assert message is not None
# Update container from response for programmatic tool calling support
last_assistant_message = await self._get_last_assistant_message()
if last_assistant_message is not None and last_assistant_message.container is not None:
self._params["container"] = last_assistant_message.container.id
self._iteration_count += 1
# If the compaction was performed, skip tool call generation this iteration
if not await self._check_and_compact():
response = await self.generate_tool_call_response()
if response is None:
log.debug("Tool call was not requested, exiting from tool runner loop.")
return
if not self._messages_modified:
self.append_messages(message, response)
self._messages_modified = False
self._cached_tool_call_response = None
async def until_done(self) -> ParsedBetaMessage[ResponseFormatT]:
"""
Consumes the tool runner stream and returns the last message if it has not been consumed yet.
If it has, it simply returns the last message.
"""
await consume_async_iterator(self)
last_message = await self._get_last_message()
assert last_message is not None
return last_message
async def generate_tool_call_response(self) -> BetaMessageParam | None:
"""Generate a MessageParam by calling tool functions with any tool use blocks from the last message.
Note the tool call response is cached, repeated calls to this method will return the same response.
None can be returned if no tool call was applicable.
"""
if self._cached_tool_call_response is not None:
log.debug("Returning cached tool call response.")
return self._cached_tool_call_response
response = await self._generate_tool_call_response()
self._cached_tool_call_response = response
return response
async def _get_last_message(self) -> ParsedBetaMessage[ResponseFormatT] | None:
if callable(self._last_message):
return await self._last_message()
return self._last_message
async def _get_last_assistant_message(self) -> ParsedBetaMessage[ResponseFormatT] | None:
last_message = await self._get_last_message()
if last_message is None or last_message.role != "assistant" or not last_message.content:
return None
return last_message
async def _get_last_assistant_message_content(self) -> list[ParsedBetaContentBlock[ResponseFormatT]] | None:
last_assistant_message = await self._get_last_assistant_message()
if last_assistant_message is None:
return None
return last_assistant_message.content
async def _generate_tool_call_response(self) -> BetaMessageParam | None:
content = await self._get_last_assistant_message_content()
if not content:
return None
tool_use_blocks = [block for block in content if block.type == "tool_use"]
if not tool_use_blocks:
return None
results: list[BetaToolResultBlockParam] = []
for tool_use in tool_use_blocks:
tool = self._tools_by_name.get(tool_use.name)
if tool is None:
warnings.warn(
f"Tool '{tool_use.name}' not found in tool runner. "
f"Available tools: {list(self._tools_by_name.keys())}. "
f"If using a raw tool definition, handle the tool call manually and use `append_messages()` to add the result. "
f"Otherwise, pass the tool using `beta_async_tool(func)` or a `@beta_async_tool` decorated function.",
UserWarning,
stacklevel=3,
)
results.append(
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": f"Error: Tool '{tool_use.name}' not found",
"is_error": True,
}
)
continue
try:
result = await tool.call(tool_use.input)
results.append({"type": "tool_result", "tool_use_id": tool_use.id, "content": result})
except ToolError as exc:
results.append(
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": exc.content,
"is_error": True,
}
)
except Exception as exc:
log.exception(f"Error occurred while calling tool: {tool.name}", exc_info=exc)
results.append(
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": repr(exc),
"is_error": True,
}
)
return {"role": "user", "content": results}
class BetaAsyncToolRunner(BaseAsyncToolRunner[ParsedBetaMessage[ResponseFormatT], ResponseFormatT]):
@override
@asynccontextmanager
async def _handle_request(self) -> AsyncIterator[ParsedBetaMessage[ResponseFormatT]]:
message = await self._client.beta.messages.parse(**self._params, **self._options)
self._last_message = message
yield message
class BetaAsyncStreamingToolRunner(BaseAsyncToolRunner[BetaAsyncMessageStream[ResponseFormatT], ResponseFormatT]):
@override
@asynccontextmanager
async def _handle_request(self) -> AsyncIterator[BetaAsyncMessageStream[ResponseFormatT]]:
async with self._client.beta.messages.stream(**self._params, **self._options) as stream:
self._last_message = stream.get_final_message
yield stream

View File

@@ -0,0 +1,442 @@
"""Helpers for integrating MCP (Model Context Protocol) SDK types with the Anthropic SDK.
These helpers reduce boilerplate when converting between MCP types and Anthropic API types.
Usage::
from anthropic.lib.tools.mcp import mcp_tool, async_mcp_tool, mcp_message
This module requires the ``mcp`` package to be installed.
"""
# pyright: reportUnknownArgumentType=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportMissingImports=false, reportUnknownParameterType=false
from __future__ import annotations
import json
import base64
from typing import Any, Iterable
from urllib.parse import urlparse
from typing_extensions import Literal
try:
from mcp.types import ( # type: ignore[import-not-found]
Tool,
TextContent,
ContentBlock,
ImageContent,
PromptMessage,
CallToolResult,
EmbeddedResource,
ReadResourceResult,
BlobResourceContents,
TextResourceContents,
)
from mcp.client.session import ClientSession # type: ignore[import-not-found]
except ImportError as _err:
raise ImportError(
"The `mcp` package is required to use MCP helpers. Install it with: pip install anthropic[mcp]. Requires Python 3.10 or higher."
) from _err
from ...types.beta import (
BetaBase64PDFSourceParam,
BetaPlainTextSourceParam,
BetaBase64ImageSourceParam,
BetaCacheControlEphemeralParam,
)
from ._beta_functions import (
ToolError,
BetaFunctionTool,
BetaAsyncFunctionTool,
BetaFunctionToolResultType,
beta_tool,
beta_async_tool,
)
from .._stainless_helpers import tag_helper
from ...types.beta.beta_tool_result_block_param import Content as BetaContent
__all__ = [
"mcp_tool",
"async_mcp_tool",
"mcp_content",
"mcp_message",
"mcp_resource_to_content",
"mcp_resource_to_file",
"UnsupportedMCPValueError",
]
# -----------------------------------------------------------------------
# Supported MIME types
# -----------------------------------------------------------------------
_SUPPORTED_IMAGE_TYPES = frozenset({"image/jpeg", "image/png", "image/gif", "image/webp"})
class _TaggedDict(dict): # type: ignore[type-arg]
"""A dict subclass that can carry a ``_stainless_helper`` attribute.
Behaves identically to a regular dict for serialization and isinstance checks,
but allows attaching tracking metadata that won't appear in JSON output.
"""
class _TaggedTuple(tuple): # type: ignore[type-arg]
"""A tuple subclass that can carry a ``_stainless_helper`` attribute."""
def _is_supported_image_type(mime_type: str) -> bool:
return mime_type in _SUPPORTED_IMAGE_TYPES
def _is_supported_resource_mime_type(mime_type: str | None) -> bool:
return (
mime_type is None
or mime_type.startswith("text/")
or mime_type == "application/pdf"
or _is_supported_image_type(mime_type)
)
# -----------------------------------------------------------------------
# Errors
# -----------------------------------------------------------------------
class UnsupportedMCPValueError(Exception):
"""Raised when an MCP value cannot be converted to a format supported by the Claude API."""
# -----------------------------------------------------------------------
# Content conversion
# -----------------------------------------------------------------------
def mcp_content(
content: ContentBlock,
*,
cache_control: BetaCacheControlEphemeralParam | None = None,
) -> BetaContent:
"""Convert a single MCP content block to an Anthropic content block.
Handles text, image, and embedded resource content types.
Raises :class:`UnsupportedMCPValueError` for audio and resource_link types.
"""
if isinstance(content, TextContent):
block = _TaggedDict({"type": "text", "text": content.text})
if cache_control is not None:
block["cache_control"] = cache_control
tag_helper(block, "mcp_content")
return block # type: ignore[return-value]
if isinstance(content, ImageContent):
if not _is_supported_image_type(content.mimeType):
raise UnsupportedMCPValueError(f"Unsupported image MIME type: {content.mimeType}")
image_block = _TaggedDict(
{
"type": "image",
"source": BetaBase64ImageSourceParam(
type="base64",
data=content.data,
media_type=content.mimeType, # type: ignore[typeddict-item]
),
}
)
if cache_control is not None:
image_block["cache_control"] = cache_control
tag_helper(image_block, "mcp_content")
return image_block # type: ignore[return-value]
if isinstance(content, EmbeddedResource):
return _resource_contents_to_block(content.resource, cache_control=cache_control)
# audio, resource_link, or unknown
content_type = getattr(content, "type", type(content).__name__)
raise UnsupportedMCPValueError(f"Unsupported MCP content type: {content_type}")
def _resource_contents_to_block(
resource: TextResourceContents | BlobResourceContents,
*,
cache_control: BetaCacheControlEphemeralParam | None = None,
) -> BetaContent:
"""Convert MCP resource contents to an Anthropic content block."""
mime_type = resource.mimeType
# Images
if mime_type is not None and _is_supported_image_type(mime_type):
if not isinstance(resource, BlobResourceContents):
raise UnsupportedMCPValueError(f"Image resource must have blob data, not text. URI: {resource.uri}")
image_block = _TaggedDict(
{
"type": "image",
"source": BetaBase64ImageSourceParam(
type="base64",
data=resource.blob,
media_type=mime_type, # type: ignore[typeddict-item]
),
}
)
if cache_control is not None:
image_block["cache_control"] = cache_control
tag_helper(image_block, "mcp_resource_to_content")
return image_block # type: ignore[return-value]
# PDFs
if mime_type == "application/pdf":
if not isinstance(resource, BlobResourceContents):
raise UnsupportedMCPValueError(f"PDF resource must have blob data, not text. URI: {resource.uri}")
pdf_block = _TaggedDict(
{
"type": "document",
"source": BetaBase64PDFSourceParam(
type="base64",
data=resource.blob,
media_type="application/pdf",
),
}
)
if cache_control is not None:
pdf_block["cache_control"] = cache_control
tag_helper(pdf_block, "mcp_resource_to_content")
return pdf_block # type: ignore[return-value]
# Text (text/*, or no MIME type)
if mime_type is None or mime_type.startswith("text/"):
if isinstance(resource, TextResourceContents):
data = resource.text
else:
data = base64.b64decode(resource.blob).decode("utf-8")
text_block = _TaggedDict(
{
"type": "document",
"source": BetaPlainTextSourceParam(
type="text",
data=data,
media_type="text/plain",
),
}
)
if cache_control is not None:
text_block["cache_control"] = cache_control
tag_helper(text_block, "mcp_resource_to_content")
return text_block # type: ignore[return-value]
raise UnsupportedMCPValueError(f'Unsupported MIME type "{mime_type}" for resource: {resource.uri}')
# -----------------------------------------------------------------------
# Message conversion
# -----------------------------------------------------------------------
def mcp_message(
message: PromptMessage,
*,
cache_control: BetaCacheControlEphemeralParam | None = None,
) -> dict[str, Any]:
"""Convert an MCP prompt message to an Anthropic ``BetaMessageParam``."""
result = _TaggedDict(
{
"role": message.role,
"content": [mcp_content(message.content, cache_control=cache_control)],
}
)
tag_helper(result, "mcp_message")
return result
# -----------------------------------------------------------------------
# Resource conversion
# -----------------------------------------------------------------------
def mcp_resource_to_content(
result: ReadResourceResult,
*,
cache_control: BetaCacheControlEphemeralParam | None = None,
) -> BetaContent:
"""Convert MCP resource contents to an Anthropic content block.
Finds the first resource with a supported MIME type from the result's
``contents`` list.
"""
if not result.contents:
raise UnsupportedMCPValueError("Resource contents array must contain at least one item")
supported = next(
(c for c in result.contents if _is_supported_resource_mime_type(c.mimeType)),
None,
)
if supported is None:
mime_types = [c.mimeType for c in result.contents if c.mimeType is not None]
raise UnsupportedMCPValueError(
f"No supported MIME type found in resource contents. Available: {', '.join(mime_types)}"
)
return _resource_contents_to_block(supported, cache_control=cache_control)
def mcp_resource_to_file(
result: ReadResourceResult,
) -> tuple[str | None, bytes, str | None]:
"""Convert MCP resource contents to a file tuple for ``files.upload()``.
Returns a ``(filename, content_bytes, mime_type)`` tuple compatible with
the SDK's ``FileTypes``.
"""
if not result.contents:
raise UnsupportedMCPValueError("Resource contents array must contain at least one item")
resource = result.contents[0]
uri_str = str(resource.uri)
# Extract filename from URI
path = urlparse(uri_str).path
name = path.rsplit("/", 1)[-1] if path else None
# Get bytes
if isinstance(resource, BlobResourceContents):
content_bytes = base64.b64decode(resource.blob)
else:
content_bytes = resource.text.encode("utf-8")
file_tuple = _TaggedTuple((name, content_bytes, resource.mimeType))
tag_helper(file_tuple, "mcp_resource_to_file")
return file_tuple
# -----------------------------------------------------------------------
# Tool result conversion (used by tool call handlers)
# -----------------------------------------------------------------------
def _convert_tool_result(result: CallToolResult) -> BetaFunctionToolResultType:
"""Convert MCP ``CallToolResult`` to a value suitable for returning from ``call()``."""
if result.isError:
raise ToolError([mcp_content(item) for item in result.content])
# If content is empty but structuredContent is present, JSON-encode it
if not result.content and result.structuredContent is not None:
return json.dumps(result.structuredContent)
return [mcp_content(item) for item in result.content]
# -----------------------------------------------------------------------
# Public factory functions
# -----------------------------------------------------------------------
def mcp_tool(
tool: Tool,
client: ClientSession,
*,
cache_control: BetaCacheControlEphemeralParam | None = None,
defer_loading: bool | None = None,
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
eager_input_streaming: bool | None = None,
input_examples: Iterable[dict[str, object]] | None = None,
strict: bool | None = None,
) -> BetaFunctionTool[Any]:
"""Convert an MCP tool to a sync runnable tool for ``tool_runner()``.
Example::
from anthropic.lib.tools.mcp import mcp_tool
tools_result = await mcp_client.list_tools()
runner = client.beta.messages.tool_runner(
model="claude-sonnet-4-20250514",
max_tokens=1024,
tools=[mcp_tool(t, mcp_client) for t in tools_result.tools],
messages=[{"role": "user", "content": "Use the available tools"}],
)
Args:
tool: An MCP tool definition from ``client.list_tools()``.
client: The MCP ``ClientSession`` used to call the tool.
cache_control: Cache control configuration.
defer_loading: If true, tool will not be included in initial system prompt.
allowed_callers: Which callers may use this tool.
eager_input_streaming: Enable eager input streaming for this tool.
input_examples: Example inputs for the tool.
strict: When true, guarantees schema validation on tool names and inputs.
"""
import anyio.from_thread
tool_name = tool.name
def call_mcp(**kwargs: Any) -> BetaFunctionToolResultType:
result = anyio.from_thread.run(client.call_tool, tool_name, kwargs)
return _convert_tool_result(result)
result = beta_tool(
call_mcp,
name=tool_name,
description=tool.description,
input_schema=tool.inputSchema,
cache_control=cache_control,
defer_loading=defer_loading,
allowed_callers=allowed_callers,
eager_input_streaming=eager_input_streaming,
input_examples=input_examples,
strict=strict,
)
tag_helper(result, "mcp_tool")
return result
def async_mcp_tool(
tool: Tool,
client: ClientSession,
*,
cache_control: BetaCacheControlEphemeralParam | None = None,
defer_loading: bool | None = None,
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
eager_input_streaming: bool | None = None,
input_examples: Iterable[dict[str, object]] | None = None,
strict: bool | None = None,
) -> BetaAsyncFunctionTool[Any]:
"""Convert an MCP tool to an async runnable tool for ``tool_runner()``.
Example::
from anthropic.lib.tools.mcp import async_mcp_tool
tools_result = await mcp_client.list_tools()
runner = await client.beta.messages.tool_runner(
model="claude-sonnet-4-20250514",
max_tokens=1024,
tools=[async_mcp_tool(t, mcp_client) for t in tools_result.tools],
messages=[{"role": "user", "content": "Use the available tools"}],
)
Args:
tool: An MCP tool definition from ``client.list_tools()``.
client: The MCP ``ClientSession`` used to call the tool.
cache_control: Cache control configuration.
defer_loading: If true, tool will not be included in initial system prompt.
allowed_callers: Which callers may use this tool.
eager_input_streaming: Enable eager input streaming for this tool.
input_examples: Example inputs for the tool.
strict: When true, guarantees schema validation on tool names and inputs.
"""
tool_name = tool.name
async def call_mcp(**kwargs: Any) -> BetaFunctionToolResultType:
result = await client.call_tool(name=tool_name, arguments=kwargs)
return _convert_tool_result(result)
result = beta_async_tool(
call_mcp,
name=tool_name,
description=tool.description,
input_schema=tool.inputSchema,
cache_control=cache_control,
defer_loading=defer_loading,
allowed_callers=allowed_callers,
eager_input_streaming=eager_input_streaming,
input_examples=input_examples,
strict=strict,
)
tag_helper(result, "mcp_tool")
return result