Skip to content
Open
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
28 changes: 28 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,34 @@ async def my_tool(x: int, ctx: Context) -> str:

The internal layers (`ToolManager.call_tool`, `Tool.run`, `Prompt.render`, `ResourceTemplate.create_resource`, etc.) now require `context` as a positional argument.

### Tool registration now accepts prebuilt `Tool` objects

`MCPServer.add_tool()` and `ToolManager.add_tool()` now expect a fully constructed `Tool` instance, matching the resource registration pattern. Build tools with `Tool.from_function(...)` or register them through the `@mcp.tool()` decorator, which still handles construction for you.

**Before (v1):**

```python
def add(a: int, b: int) -> int:
return a + b

mcp.add_tool(add)
```

**After (v2):**

```python
from mcp.server.mcpserver.tools import Tool


def add(a: int, b: int) -> int:
return a + b


mcp.add_tool(Tool.from_function(add))
```

If you need to customize the tool metadata before registration, build the `Tool` first and then pass it to `add_tool()`.

### Registering lowlevel handlers on `MCPServer` (workaround)

`MCPServer` does not expose public APIs for `subscribe_resource`, `unsubscribe_resource`, or `set_logging_level` handlers. In v1, the workaround was to reach into the private lowlevel server and use its decorator methods:
Expand Down
41 changes: 5 additions & 36 deletions src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,45 +455,13 @@ async def read_resource(
# If an exception happens when reading the resource, we should not leak the exception to the client.
raise ResourceError(f"Error reading resource {uri}") from exc

def add_tool(
self,
fn: Callable[..., Any],
name: str | None = None,
title: str | None = None,
description: str | None = None,
annotations: ToolAnnotations | None = None,
icons: list[Icon] | None = None,
meta: dict[str, Any] | None = None,
structured_output: bool | None = None,
) -> None:
def add_tool(self, tool: Tool) -> None:
"""Add a tool to the server.

The tool function can optionally request a Context object by adding a parameter
with the Context type annotation. See the @tool decorator for examples.

Args:
fn: The function to register as a tool
name: Optional name for the tool (defaults to function name)
title: Optional human-readable title for the tool
description: Optional description of what the tool does
annotations: Optional ToolAnnotations providing additional tool information
icons: Optional list of icons for the tool
meta: Optional metadata dictionary for the tool
structured_output: Controls whether the tool's output is structured or unstructured
- If None, auto-detects based on the function's return type annotation
- If True, creates a structured tool (return type annotation permitting)
- If False, unconditionally creates an unstructured tool
tool: A Tool instance to add
"""
self._tool_manager.add_tool(
fn,
name=name,
title=title,
description=description,
annotations=annotations,
icons=icons,
meta=meta,
structured_output=structured_output,
)
self._tool_manager.add_tool(tool)

def remove_tool(self, name: str) -> None:
"""Remove a tool from the server by name.
Expand Down Expand Up @@ -562,7 +530,7 @@ async def async_tool(x: int, context: Context) -> str:
)

def decorator(fn: _CallableT) -> _CallableT:
self.add_tool(
tool = Tool.from_function(
fn,
name=name,
title=title,
Expand All @@ -572,6 +540,7 @@ def decorator(fn: _CallableT) -> _CallableT:
meta=meta,
structured_output=structured_output,
)
self.add_tool(tool)
return fn

return decorator
Expand Down
10 changes: 7 additions & 3 deletions src/mcp/server/mcpserver/tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from functools import cached_property
from typing import TYPE_CHECKING, Any

from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator

from mcp.server.mcpserver.exceptions import ToolError
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
Expand Down Expand Up @@ -36,6 +36,12 @@ class Tool(BaseModel):
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this tool")
meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this tool")

@field_validator("name")
@classmethod
def validate_name(cls, name: str) -> str:
validate_and_warn_tool_name(name)
return name

@cached_property
def output_schema(self) -> dict[str, Any] | None:
return self.fn_metadata.output_schema
Expand All @@ -56,8 +62,6 @@ def from_function(
"""Create a Tool from a function."""
func_name = name or fn.__name__

validate_and_warn_tool_name(func_name)

if func_name == "<lambda>":
raise ValueError("You must provide a name for lambda functions")

Expand Down
30 changes: 4 additions & 26 deletions src/mcp/server/mcpserver/tools/tool_manager.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
from __future__ import annotations

from collections.abc import Callable
from typing import TYPE_CHECKING, Any

from mcp.server.mcpserver.exceptions import ToolError
from mcp.server.mcpserver.tools.base import Tool
from mcp.server.mcpserver.utilities.logging import get_logger
from mcp.types import Icon, ToolAnnotations

if TYPE_CHECKING:
from mcp.server.context import LifespanContextT, RequestT
Expand All @@ -25,13 +23,10 @@ def __init__(
tools: list[Tool] | None = None,
):
self._tools: dict[str, Tool] = {}
self.warn_on_duplicate_tools = warn_on_duplicate_tools
if tools is not None:
for tool in tools:
if warn_on_duplicate_tools and tool.name in self._tools:
logger.warning(f"Tool already exists: {tool.name}")
self._tools[tool.name] = tool

self.warn_on_duplicate_tools = warn_on_duplicate_tools
self.add_tool(tool)

def get_tool(self, name: str) -> Tool | None:
"""Get tool by name."""
Expand All @@ -43,26 +38,9 @@ def list_tools(self) -> list[Tool]:

def add_tool(
self,
fn: Callable[..., Any],
name: str | None = None,
title: str | None = None,
description: str | None = None,
annotations: ToolAnnotations | None = None,
icons: list[Icon] | None = None,
meta: dict[str, Any] | None = None,
structured_output: bool | None = None,
tool: Tool,
) -> Tool:
"""Add a tool to the server."""
tool = Tool.from_function(
fn,
name=name,
title=title,
description=description,
annotations=annotations,
icons=icons,
meta=meta,
structured_output=structured_output,
)
"""Add a tool to the manager."""
existing = self._tools.get(tool.name)
if existing:
if self.warn_on_duplicate_tools:
Expand Down
15 changes: 15 additions & 0 deletions src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,9 @@ def _try_create_model_and_schema(
elif isinstance(type_expr, GenericAlias):
origin = get_origin(type_expr)

if origin in (list, tuple, set, frozenset, Sequence) and _annotation_contains_any(type_expr):
return None, None, False

# Special case: dict with string keys can use RootModel
if origin is dict:
args = get_args(type_expr)
Expand Down Expand Up @@ -474,6 +477,18 @@ def _create_wrapped_model(func_name: str, annotation: Any) -> type[BaseModel]:
return create_model(model_name, result=annotation)


def _annotation_contains_any(annotation: Any) -> bool:
"""Return True if a type annotation contains `Any` anywhere within it."""
if annotation is Any:
return True

origin = get_origin(annotation)
if origin is None:
return False

return any(_annotation_contains_any(arg) for arg in get_args(annotation) if arg is not Ellipsis)


def _create_dict_model(func_name: str, dict_annotation: Any) -> type[BaseModel]:
"""Create a RootModel for dict[str, T] types."""
# TODO(Marcelo): We should not rely on RootModel for this.
Expand Down
7 changes: 7 additions & 0 deletions tests/server/mcpserver/test_func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,9 @@ def func_list_str() -> list[str]: # pragma: no cover
def func_dict_str_int() -> dict[str, int]: # pragma: no cover
return {"a": 1, "b": 2}

def func_list_any() -> list[Any]: # pragma: no cover
return ["a", "b", "c"]

def func_union() -> str | int: # pragma: no cover
return "hello"

Expand All @@ -689,6 +692,10 @@ def func_optional() -> str | None: # pragma: no cover
"title": "func_list_strOutput",
}

# Test list[Any] - should stay unstructured because it can contain arbitrary non-serializable values
meta = func_metadata(func_list_any)
assert meta.output_schema is None

# Test dict[str, int] - should NOT be wrapped
meta = func_metadata(func_dict_str_int)
assert meta.output_schema == {
Expand Down
Loading
Loading