Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Bug fixes

* `ContentToolRequest` is (once again) serializable to/from JSON via Pydantic. (#164)
* `.register_tool(model=model)` no longer unexpectedly errors when `model` contains `pydantic.Field(alias='_my_alias')`. (#161)

### Changes

* `.register_tool(annotations=annotations)` drops support for `mcp.types.ToolAnnotations()` and instead expects a dictionary of the same info. (#164)


## [0.11.0] - 2025-08-26

Expand Down
16 changes: 10 additions & 6 deletions chatlas/_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
ContentText,
ContentToolRequest,
ContentToolResult,
ToolInfo,
)
from ._display import (
EchoDisplayOptions,
Expand All @@ -52,7 +53,7 @@
from ._utils import MISSING, MISSING_TYPE, html_escape, wrap_async

if TYPE_CHECKING:
from mcp.types import ToolAnnotations
from ._content import ToolAnnotations


class TokensDict(TypedDict):
Expand Down Expand Up @@ -1622,7 +1623,6 @@ def add(a: int, b: int) -> int:
name and docstring of the function.
annotations
Additional properties that describe the tool and its behavior.
Should be a `from mcp.types import ToolAnnotations` instance.

Raises
------
Expand Down Expand Up @@ -1937,7 +1937,9 @@ def _chat_impl(
all_results: list[ContentToolResult] = []
for x in turn.contents:
if isinstance(x, ContentToolRequest):
x.tool = self._tools.get(x.name)
tool = self._tools.get(x.name)
if tool is not None:
x.tool = ToolInfo.from_tool(tool)
if echo == "output":
self._echo_content(f"\n\n{x}\n\n")
if content == "all":
Expand Down Expand Up @@ -1998,7 +2000,9 @@ async def _chat_impl_async(
all_results: list[ContentToolResult] = []
for x in turn.contents:
if isinstance(x, ContentToolRequest):
x.tool = self._tools.get(x.name)
tool = self._tools.get(x.name)
if tool is not None:
x.tool = ToolInfo.from_tool(tool)
if echo == "output":
self._echo_content(f"\n\n{x}\n\n")
if content == "all":
Expand Down Expand Up @@ -2156,7 +2160,7 @@ def emit(text: str | Content):
self._turns.extend([user_turn, turn])

def _invoke_tool(self, request: ContentToolRequest):
tool = request.tool
tool = self._tools.get(request.name)
func = tool.func if tool is not None else None

if func is None:
Expand Down Expand Up @@ -2204,7 +2208,7 @@ def _as_generator(res):
yield self._handle_tool_error_result(request, e)

async def _invoke_tool_async(self, request: ContentToolRequest):
tool = request.tool
tool = self._tools.get(request.name)

if tool is None:
yield self._handle_tool_error_result(
Expand Down
96 changes: 93 additions & 3 deletions chatlas/_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,59 @@
import orjson
from pydantic import BaseModel, ConfigDict

from ._typing_extensions import NotRequired, TypedDict

if TYPE_CHECKING:
from ._tools import Tool


class ToolAnnotations(TypedDict, total=False):
"""
Additional properties describing a Tool to clients.

NOTE: all properties in ToolAnnotations are **hints**.
They are not guaranteed to provide a faithful description of
tool behavior (including descriptive properties like `title`).

Clients should never make tool use decisions based on ToolAnnotations
received from untrusted servers.
"""

title: NotRequired[str]
"""A human-readable title for the tool."""

readOnlyHint: NotRequired[bool]
"""
If true, the tool does not modify its environment.
Default: false
"""

destructiveHint: NotRequired[bool]
"""
If true, the tool may perform destructive updates to its environment.
If false, the tool performs only additive updates.
(This property is meaningful only when `readOnlyHint == false`)
Default: true
"""

idempotentHint: NotRequired[bool]
"""
If true, calling the tool repeatedly with the same arguments
will have no additional effect on the its environment.
(This property is meaningful only when `readOnlyHint == false`)
Default: false
"""

openWorldHint: NotRequired[bool]
"""
If true, this tool may interact with an "open world" of external
entities. If false, the tool's domain of interaction is closed.
For example, the world of a web search tool is open, whereas that
of a memory tool is not.
Default: true
"""


ImageContentTypes = Literal[
"image/png",
"image/jpeg",
Expand All @@ -19,6 +69,45 @@
Allowable content types for images.
"""


class ToolInfo(BaseModel):
"""
Serializable tool information

This contains only the serializable parts of a Tool that are needed
for ContentToolRequest to be JSON-serializable. This allows tool
metadata to be preserved without including the non-serializable
function reference.

Parameters
----------
name
The name of the tool.
description
A description of what the tool does.
parameters
A dictionary describing the input parameters and their types.
annotations
Additional properties that describe the tool and its behavior.
"""

name: str
description: str
parameters: dict[str, Any]
annotations: Optional[ToolAnnotations] = None

@classmethod
def from_tool(cls, tool: "Tool") -> "ToolInfo":
"""Create a ToolInfo from a Tool instance."""
func_schema = tool.schema["function"]
return cls(
name=tool.name,
description=func_schema.get("description", ""),
parameters=func_schema.get("parameters", {}),
annotations=tool.annotations,
)


ContentTypeEnum = Literal[
"text",
"image_remote",
Expand Down Expand Up @@ -175,14 +264,15 @@ class ContentToolRequest(Content):
arguments
The arguments to pass to the tool/function.
tool
The tool/function to be called. This is set internally by chatlas's tool
calling loop.
Serializable information about the tool. This is set internally by
chatlas's tool calling loop and contains only the metadata needed
for serialization (name, description, parameters, annotations).
"""

id: str
name: str
arguments: object
tool: Optional["Tool"] = None
tool: Optional[ToolInfo] = None

content_type: ContentTypeEnum = "tool_request"

Expand Down
25 changes: 18 additions & 7 deletions chatlas/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@

import inspect
import warnings
from typing import TYPE_CHECKING, Any, AsyncGenerator, Awaitable, Callable, Optional
from typing import (
TYPE_CHECKING,
Any,
AsyncGenerator,
Awaitable,
Callable,
Optional,
cast,
)

import openai
from pydantic import BaseModel, Field, create_model
Expand All @@ -12,6 +20,7 @@
ContentToolResult,
ContentToolResultImage,
ContentToolResultResource,
ToolAnnotations,
)

__all__ = (
Expand All @@ -22,7 +31,6 @@
if TYPE_CHECKING:
from mcp import ClientSession as MCPClientSession
from mcp import Tool as MCPTool
from mcp.types import ToolAnnotations
from openai.types.chat import ChatCompletionToolParam


Expand All @@ -44,8 +52,7 @@ class Tool:
parameters
A dictionary describing the input parameters and their types.
annotations
Additional properties that describe the tool and its behavior. Should be
a `from mcp.types import ToolAnnotations` instance.
Additional properties that describe the tool and its behavior.
"""

func: Callable[..., Any] | Callable[..., Awaitable[Any]]
Expand Down Expand Up @@ -98,8 +105,7 @@ def from_func(
Note that the name and docstring of the model takes precedence over the
name and docstring of the function.
annotations
Additional properties that describe the tool and its behavior. Should be
a `from mcp.types import ToolAnnotations` instance.
Additional properties that describe the tool and its behavior.

Returns
-------
Expand Down Expand Up @@ -208,12 +214,17 @@ async def _call(**args: Any) -> AsyncGenerator[ContentToolResult, None]:

params = mcp_tool_input_schema_to_param_schema(mcp_tool.inputSchema)

# Convert MCP ToolAnnotations to our TypedDict format
annotations = None
if mcp_tool.annotations:
annotations = cast(ToolAnnotations, mcp_tool.annotations.model_dump())

return cls(
func=_utils.wrap_async(_call),
name=mcp_tool.name,
description=mcp_tool.description or "",
parameters=params,
annotations=mcp_tool.annotations,
annotations=annotations,
)


Expand Down
2 changes: 1 addition & 1 deletion chatlas/_typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# Even though TypedDict is available in Python 3.8, because it's used with NotRequired,
# they should both come from the same typing module.
# https://peps.python.org/pep-0655/#usage-in-python-3-11
if sys.version_info >= (3, 11):
if sys.version_info >= (3, 12):
from typing import NotRequired, Required, TypedDict
else:
from typing_extensions import NotRequired, Required, TypedDict
Expand Down
4 changes: 4 additions & 0 deletions chatlas/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
ContentToolRequest,
ContentToolResult,
ImageContentTypes,
ToolAnnotations,
ToolInfo,
)
from .._provider import ModelInfo
from .._tokens import TokenUsage
Expand All @@ -32,6 +34,8 @@
"ImageContentTypes",
"SubmitInputArgsT",
"TokenUsage",
"ToolAnnotations",
"ToolInfo",
"MISSING_TYPE",
"MISSING",
"ModelInfo",
Expand Down
2 changes: 2 additions & 0 deletions docs/_quarto.yml
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ quartodoc:
- types.MISSING
- types.SubmitInputArgsT
- types.TokenUsage
- types.ToolAnnotations
- types.ToolInfo



Expand Down
8 changes: 2 additions & 6 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,7 @@ def get_date():


def assert_tools_simple_stream_content(chat_fun: ChatFun):
try:
from mcp.types import ToolAnnotations
except ImportError:
pytest.skip("mcp is not installed")
return
from chatlas._content import ToolAnnotations

chat = chat_fun(system_prompt="Be very terse, not even punctuation.")

Expand All @@ -114,7 +110,7 @@ def get_date():
assert request[0].tool is not None
assert request[0].tool.name == "get_date"
assert request[0].tool.annotations is not None
assert request[0].tool.annotations.title == "Get Date"
assert request[0].tool.annotations["title"] == "Get Date"

# Emits a response (with a reference to the request)
response = [x for x in chunks if isinstance(x, ContentToolResult)]
Expand Down
Loading