Skip to content

Commit 9489751

Browse files
committed
feat(helpers): add conversion helpers for MCP tools, prompts, and resources (#1383)
1 parent 6b54405 commit 9489751

File tree

14 files changed

+1234
-30
lines changed

14 files changed

+1234
-30
lines changed

examples/mcp_tool_runner.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Example showing how to use MCP helpers with tool_runner().
2+
3+
Connects to an MCP server, converts its tools to Anthropic-compatible tools
4+
using async_mcp_tool(), and runs them in a tool_runner() loop.
5+
6+
Requires: pip install anthropic[mcp]
7+
Requires: Python 3.10+
8+
"""
9+
10+
import asyncio
11+
12+
import rich
13+
from mcp import ClientSession
14+
from mcp.client.stdio import StdioServerParameters, stdio_client
15+
16+
from anthropic import AsyncAnthropic
17+
from anthropic.lib.tools.mcp import async_mcp_tool
18+
19+
client = AsyncAnthropic()
20+
21+
22+
async def main() -> None:
23+
# Connect to a local MCP server via stdio
24+
# This example uses the MCP filesystem server; replace with your own server
25+
server_params = StdioServerParameters(
26+
command="npx",
27+
args=["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
28+
)
29+
30+
async with stdio_client(server_params) as (read, write):
31+
async with ClientSession(read, write) as mcp_client:
32+
await mcp_client.initialize()
33+
34+
# List available tools from the MCP server and convert them
35+
tools_result = await mcp_client.list_tools()
36+
tools = [async_mcp_tool(t, mcp_client) for t in tools_result.tools]
37+
38+
print(f"Connected to MCP server with {len(tools)} tools:")
39+
for tool in tools:
40+
print(f" - {tool.name}")
41+
print()
42+
43+
# Run a conversation with tool_runner()
44+
runner = client.beta.messages.tool_runner(
45+
model="claude-sonnet-4-5-20250929",
46+
max_tokens=1024,
47+
tools=tools,
48+
messages=[{"role": "user", "content": "List the files in /tmp"}],
49+
)
50+
async for message in runner:
51+
rich.print(message)
52+
53+
54+
asyncio.run(main())

helpers.md

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ The stream will be cancelled when the context manager exits but you can also clo
2828

2929
See an example of streaming helpers in action in [`examples/messages_stream.py`](examples/messages_stream.py).
3030

31-
> [!NOTE]
31+
> [!NOTE]
3232
> The synchronous client has the same interface just without `async/await`.
3333
3434
### Lenses
@@ -131,7 +131,91 @@ Blocks until the stream has been read to completion and returns the accumulated
131131

132132
#### `await .get_final_text()`
133133

134-
> [!NOTE]
134+
> [!NOTE]
135135
> Currently the API will only ever return 1 content block
136136
137137
Blocks until the stream has been read to completion and returns all `text` content blocks concatenated together.
138+
139+
## MCP Helpers
140+
141+
This SDK provides helpers for integrating with [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers. These helpers convert MCP types to Anthropic API types, reducing boilerplate when working with MCP tools, prompts, and resources.
142+
143+
> **Note:** The Claude API also supports an [`mcp_servers` parameter](https://docs.anthropic.com/en/docs/agents-and-tools/mcp) that lets Claude connect directly to remote MCP servers.
144+
>
145+
> - Use `mcp_servers` when you have remote servers accessible via URL and only need tool support.
146+
> - Use the MCP helpers when you need local MCP servers, prompts, resources, or more control over the MCP connection.
147+
148+
> **Requires:** `pip install anthropic[mcp]` (Python 3.10+)
149+
150+
### Using MCP tools with tool_runner
151+
152+
```py
153+
from anthropic import AsyncAnthropic
154+
from anthropic.lib.tools.mcp import async_mcp_tool
155+
from mcp import ClientSession
156+
from mcp.client.stdio import stdio_client, StdioServerParameters
157+
158+
client = AsyncAnthropic()
159+
160+
async with stdio_client(StdioServerParameters(command="mcp-server")) as (read, write):
161+
async with ClientSession(read, write) as mcp_client:
162+
await mcp_client.initialize()
163+
164+
tools_result = await mcp_client.list_tools()
165+
runner = await client.beta.messages.tool_runner(
166+
model="claude-sonnet-4-20250514",
167+
max_tokens=1024,
168+
messages=[{"role": "user", "content": "Use the available tools"}],
169+
tools=[async_mcp_tool(t, mcp_client) for t in tools_result.tools],
170+
)
171+
async for message in runner:
172+
print(message)
173+
```
174+
175+
> [!TIP]
176+
> If you're using the sync client, replace `async_mcp_tool` with `mcp_tool`.
177+
178+
### Using MCP prompts
179+
180+
```py
181+
from anthropic.lib.tools.mcp import mcp_message
182+
183+
prompt = await mcp_client.get_prompt(name="my-prompt")
184+
response = await client.beta.messages.create(
185+
model="claude-sonnet-4-20250514",
186+
max_tokens=1024,
187+
messages=[mcp_message(m) for m in prompt.messages],
188+
)
189+
```
190+
191+
### Using MCP resources as content
192+
193+
```py
194+
from anthropic.lib.tools.mcp import mcp_resource_to_content
195+
196+
resource = await mcp_client.read_resource(uri="file:///path/to/doc.txt")
197+
response = await client.beta.messages.create(
198+
model="claude-sonnet-4-20250514",
199+
max_tokens=1024,
200+
messages=[{
201+
"role": "user",
202+
"content": [
203+
mcp_resource_to_content(resource),
204+
{"type": "text", "text": "Summarize this document"},
205+
],
206+
}],
207+
)
208+
```
209+
210+
### Uploading MCP resources as files
211+
212+
```py
213+
from anthropic.lib.tools.mcp import mcp_resource_to_file
214+
215+
resource = await mcp_client.read_resource(uri="file:///path/to/data.json")
216+
uploaded = await client.beta.files.upload(file=mcp_resource_to_file(resource))
217+
```
218+
219+
### Error handling
220+
221+
The conversion functions raise `UnsupportedMCPValueError` if an MCP value cannot be converted to a format supported by the Claude API (e.g., unsupported content type like audio, unsupported MIME type).

pyproject.toml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ classifiers = [
4242
aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9"]
4343
vertex = ["google-auth[requests] >=2, <3"]
4444
bedrock = ["boto3 >= 1.28.57", "botocore >= 1.31.57"]
45+
mcp = ["mcp>=1.0; python_version >= '3.10'"]
4546

4647
[project.urls]
4748
Homepage = "https://github.com/anthropics/anthropic-sdk-python"
@@ -50,11 +51,17 @@ Repository = "https://github.com/anthropics/anthropic-sdk-python"
5051
[tool.uv]
5152
managed = true
5253
required-version = ">=0.9"
54+
# Ensure the lockfile always uses public PyPI, regardless of contributor's global uv config
55+
index = [{ url = "https://pypi.org/simple", default = true }]
5356
conflicts = [
5457
[
5558
{ group = "pydantic-v1" },
5659
{ group = "pydantic-v2" },
5760
],
61+
[
62+
{ group = "pydantic-v1" },
63+
{ extra = "mcp" },
64+
],
5865
]
5966

6067
[dependency-groups]
@@ -147,6 +154,7 @@ exclude = [
147154
"_dev",
148155
".venv",
149156
".nox",
157+
"examples/mcp_tool_runner.py", # mcp requires Python 3.10+, lint runs on 3.9
150158
]
151159

152160
reportImplicitOverride = true
@@ -165,7 +173,7 @@ show_error_codes = true
165173
#
166174
# We also exclude our `tests` as mypy doesn't always infer
167175
# types correctly and Pyright will still catch any type errors.
168-
exclude = ['src/anthropic/_files.py', '_dev/.*.py', 'tests/.*', 'examples/mcp_server_weather.py', 'examples/tools_with_mcp.py', 'examples/memory/basic.py', 'src/anthropic/lib/_parse/_transform.py', 'src/anthropic/lib/tools/_beta_functions.py']
176+
exclude = ['src/anthropic/_files.py', '_dev/.*.py', 'tests/.*', 'examples/mcp_server_weather.py', 'examples/mcp_tool_runner.py', 'examples/tools_with_mcp.py', 'examples/memory/basic.py', 'src/anthropic/lib/_parse/_transform.py', 'src/anthropic/lib/tools/_beta_functions.py']
169177

170178
strict_equality = true
171179
implicit_reexport = true
@@ -210,6 +218,10 @@ ignore_missing_imports = true
210218
module = "anthropic.lib.vertex._auth"
211219
disallow_untyped_calls = false
212220

221+
[[tool.mypy.overrides]]
222+
module = "tests.lib.tools.test_mcp_tool"
223+
follow_imports = "skip"
224+
213225
[tool.ruff]
214226
line-length = 120
215227
output-format = "grouped"

scripts/test

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,9 @@ function run_tests() {
6666
# Skip Pydantic v1 tests on latest Python (not supported)
6767
if [[ "$UV_PYTHON" != "$PY_VERSION_MAX" ]]; then
6868
echo "==> Running tests with Pydantic v1"
69-
uv run --isolated --all-extras --group=pydantic-v1 pytest "$@"
69+
uv run --isolated --all-extras --no-extra=mcp --group=pydantic-v1 pytest "$@"
7070
fi
71+
7172
}
7273

7374
# If UV_PYTHON is already set in the environment, just run the command once

src/anthropic/_utils/_typing.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def is_typevar(typ: type) -> bool:
5353

5454
_TYPE_ALIAS_TYPES: tuple[type[typing_extensions.TypeAliasType], ...] = (typing_extensions.TypeAliasType,)
5555
if sys.version_info >= (3, 12):
56-
_TYPE_ALIAS_TYPES = (*_TYPE_ALIAS_TYPES, typing.TypeAliasType)
56+
_TYPE_ALIAS_TYPES = (*_TYPE_ALIAS_TYPES, typing.TypeAliasType) # type: ignore[arg-type]
5757

5858

5959
def is_type_alias_type(tp: Any, /) -> TypeIs[typing_extensions.TypeAliasType]:
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Tracking for SDK helper usage via the x-stainless-helper header."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any, Dict, cast
6+
7+
_HELPER_ATTR = "_stainless_helper"
8+
9+
10+
def tag_helper(obj: Any, name: str) -> None:
11+
"""Mark an object as created by a named SDK helper."""
12+
try:
13+
object.__setattr__(obj, _HELPER_ATTR, name)
14+
except (AttributeError, TypeError):
15+
pass
16+
17+
18+
def get_helper_tag(obj: object) -> str | None:
19+
"""Get the helper name from an object, if any."""
20+
return getattr(obj, _HELPER_ATTR, None) # type: ignore[return-value]
21+
22+
23+
def collect_helpers(
24+
tools: Any = None,
25+
messages: Any = None,
26+
) -> list[str]:
27+
"""Collect deduplicated helper names from tools and messages."""
28+
helpers: set[str] = set()
29+
30+
if tools:
31+
for tool in tools:
32+
tag = get_helper_tag(tool)
33+
if tag is not None:
34+
helpers.add(tag)
35+
36+
if messages:
37+
for message in messages:
38+
tag = get_helper_tag(message)
39+
if tag is not None:
40+
helpers.add(tag)
41+
42+
# Check content blocks within messages
43+
if isinstance(message, dict):
44+
blocks: Any = cast(Dict[str, Any], message).get("content")
45+
else:
46+
blocks = getattr(message, "content", None)
47+
if isinstance(blocks, list):
48+
for block in cast(list[object], blocks):
49+
tag = get_helper_tag(block)
50+
if tag is not None:
51+
helpers.add(tag)
52+
53+
return list(helpers)
54+
55+
56+
def stainless_helper_header(
57+
tools: Any = None,
58+
messages: Any = None,
59+
) -> dict[str, str]:
60+
"""Build x-stainless-helper header dict from tools and messages.
61+
62+
Returns an empty dict if no helpers are found.
63+
"""
64+
helpers = collect_helpers(tools, messages)
65+
if not helpers:
66+
return {}
67+
return {"x-stainless-helper": ", ".join(helpers)}
68+
69+
70+
def stainless_helper_header_from_file(file: object) -> dict[str, str]:
71+
"""Build x-stainless-helper header dict from a file object."""
72+
tag = get_helper_tag(file)
73+
if tag is None:
74+
return {}
75+
return {"x-stainless-helper": tag}

src/anthropic/lib/tools/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from ._beta_runner import BetaToolRunner, BetaAsyncToolRunner, BetaStreamingToolRunner, BetaAsyncStreamingToolRunner
22
from ._beta_functions import (
3+
ToolError,
34
BetaFunctionTool,
45
BetaAsyncFunctionTool,
56
BetaBuiltinFunctionTool,
@@ -24,4 +25,5 @@
2425
"BetaFunctionToolResultType",
2526
"BetaAbstractMemoryTool",
2627
"BetaAsyncAbstractMemoryTool",
28+
"ToolError",
2729
]

0 commit comments

Comments
 (0)