Skip to content

Commit a5bfdfa

Browse files
authored
Merge branch 'main' into fix/sse-server-message-path
2 parents e248cb3 + 1a330ac commit a5bfdfa

File tree

10 files changed

+263
-14
lines changed

10 files changed

+263
-14
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ dev = [
5353
"pytest-flakefinder>=1.1.0",
5454
"pytest-xdist>=3.6.1",
5555
"pytest-examples>=0.0.14",
56+
"pytest-pretty>=1.2.0",
5657
]
5758
docs = [
5859
"mkdocs>=1.6.1",

src/mcp/client/session.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,10 @@ async def unsubscribe_resource(self, uri: AnyUrl) -> types.EmptyResult:
254254
)
255255

256256
async def call_tool(
257-
self, name: str, arguments: dict[str, Any] | None = None
257+
self,
258+
name: str,
259+
arguments: dict[str, Any] | None = None,
260+
read_timeout_seconds: timedelta | None = None,
258261
) -> types.CallToolResult:
259262
"""Send a tools/call request."""
260263
return await self.send_request(
@@ -265,6 +268,7 @@ async def call_tool(
265268
)
266269
),
267270
types.CallToolResult,
271+
request_read_timeout_seconds=read_timeout_seconds,
268272
)
269273

270274
async def list_prompts(self) -> types.ListPromptsResult:

src/mcp/server/fastmcp/server.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
GetPromptResult,
4242
ImageContent,
4343
TextContent,
44+
ToolAnnotations,
4445
)
4546
from mcp.types import Prompt as MCPPrompt
4647
from mcp.types import PromptArgument as MCPPromptArgument
@@ -180,6 +181,7 @@ async def list_tools(self) -> list[MCPTool]:
180181
name=info.name,
181182
description=info.description,
182183
inputSchema=info.parameters,
184+
annotations=info.annotations,
183185
)
184186
for info in tools
185187
]
@@ -248,6 +250,7 @@ def add_tool(
248250
fn: AnyFunction,
249251
name: str | None = None,
250252
description: str | None = None,
253+
annotations: ToolAnnotations | None = None,
251254
) -> None:
252255
"""Add a tool to the server.
253256
@@ -258,11 +261,17 @@ def add_tool(
258261
fn: The function to register as a tool
259262
name: Optional name for the tool (defaults to function name)
260263
description: Optional description of what the tool does
264+
annotations: Optional ToolAnnotations providing additional tool information
261265
"""
262-
self._tool_manager.add_tool(fn, name=name, description=description)
266+
self._tool_manager.add_tool(
267+
fn, name=name, description=description, annotations=annotations
268+
)
263269

264270
def tool(
265-
self, name: str | None = None, description: str | None = None
271+
self,
272+
name: str | None = None,
273+
description: str | None = None,
274+
annotations: ToolAnnotations | None = None,
266275
) -> Callable[[AnyFunction], AnyFunction]:
267276
"""Decorator to register a tool.
268277
@@ -273,6 +282,7 @@ def tool(
273282
Args:
274283
name: Optional name for the tool (defaults to function name)
275284
description: Optional description of what the tool does
285+
annotations: Optional ToolAnnotations providing additional tool information
276286
277287
Example:
278288
@server.tool()
@@ -297,7 +307,9 @@ async def async_tool(x: int, context: Context) -> str:
297307
)
298308

299309
def decorator(fn: AnyFunction) -> AnyFunction:
300-
self.add_tool(fn, name=name, description=description)
310+
self.add_tool(
311+
fn, name=name, description=description, annotations=annotations
312+
)
301313
return fn
302314

303315
return decorator

src/mcp/server/fastmcp/tools/base.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from mcp.server.fastmcp.exceptions import ToolError
1010
from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
11+
from mcp.types import ToolAnnotations
1112

1213
if TYPE_CHECKING:
1314
from mcp.server.fastmcp.server import Context
@@ -30,6 +31,9 @@ class Tool(BaseModel):
3031
context_kwarg: str | None = Field(
3132
None, description="Name of the kwarg that should receive context"
3233
)
34+
annotations: ToolAnnotations | None = Field(
35+
None, description="Optional annotations for the tool"
36+
)
3337

3438
@classmethod
3539
def from_function(
@@ -38,9 +42,10 @@ def from_function(
3842
name: str | None = None,
3943
description: str | None = None,
4044
context_kwarg: str | None = None,
45+
annotations: ToolAnnotations | None = None,
4146
) -> Tool:
4247
"""Create a Tool from a function."""
43-
from mcp.server.fastmcp import Context
48+
from mcp.server.fastmcp.server import Context
4449

4550
func_name = name or fn.__name__
4651

@@ -73,6 +78,7 @@ def from_function(
7378
fn_metadata=func_arg_metadata,
7479
is_async=is_async,
7580
context_kwarg=context_kwarg,
81+
annotations=annotations,
7682
)
7783

7884
async def run(

src/mcp/server/fastmcp/tools/tool_manager.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from mcp.server.fastmcp.tools.base import Tool
88
from mcp.server.fastmcp.utilities.logging import get_logger
99
from mcp.shared.context import LifespanContextT
10+
from mcp.types import ToolAnnotations
1011

1112
if TYPE_CHECKING:
1213
from mcp.server.fastmcp.server import Context
@@ -35,9 +36,12 @@ def add_tool(
3536
fn: Callable[..., Any],
3637
name: str | None = None,
3738
description: str | None = None,
39+
annotations: ToolAnnotations | None = None,
3840
) -> Tool:
3941
"""Add a tool to the server."""
40-
tool = Tool.from_function(fn, name=name, description=description)
42+
tool = Tool.from_function(
43+
fn, name=name, description=description, annotations=annotations
44+
)
4145
existing = self._tools.get(tool.name)
4246
if existing:
4347
if self.warn_on_duplicate_tools:

src/mcp/shared/session.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ def __init__(
185185
self._request_id = 0
186186
self._receive_request_type = receive_request_type
187187
self._receive_notification_type = receive_notification_type
188-
self._read_timeout_seconds = read_timeout_seconds
188+
self._session_read_timeout_seconds = read_timeout_seconds
189189
self._in_flight = {}
190190

191191
self._exit_stack = AsyncExitStack()
@@ -213,10 +213,12 @@ async def send_request(
213213
self,
214214
request: SendRequestT,
215215
result_type: type[ReceiveResultT],
216+
request_read_timeout_seconds: timedelta | None = None,
216217
) -> ReceiveResultT:
217218
"""
218219
Sends a request and wait for a response. Raises an McpError if the
219-
response contains an error.
220+
response contains an error. If a request read timeout is provided, it
221+
will take precedence over the session read timeout.
220222
221223
Do not use this method to emit notifications! Use send_notification()
222224
instead.
@@ -243,12 +245,15 @@ async def send_request(
243245

244246
await self._write_stream.send(JSONRPCMessage(jsonrpc_request))
245247

248+
# request read timeout takes precedence over session read timeout
249+
timeout = None
250+
if request_read_timeout_seconds is not None:
251+
timeout = request_read_timeout_seconds.total_seconds()
252+
elif self._session_read_timeout_seconds is not None:
253+
timeout = self._session_read_timeout_seconds.total_seconds()
254+
246255
try:
247-
with anyio.fail_after(
248-
None
249-
if self._read_timeout_seconds is None
250-
else self._read_timeout_seconds.total_seconds()
251-
):
256+
with anyio.fail_after(timeout):
252257
response_or_error = await response_stream_reader.receive()
253258
except TimeoutError:
254259
raise McpError(
@@ -257,7 +262,7 @@ async def send_request(
257262
message=(
258263
f"Timed out while waiting for response to "
259264
f"{request.__class__.__name__}. Waited "
260-
f"{self._read_timeout_seconds} seconds."
265+
f"{timeout} seconds."
261266
),
262267
)
263268
)

src/mcp/types.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,54 @@ class ListToolsRequest(PaginatedRequest[RequestParams | None, Literal["tools/lis
705705
params: RequestParams | None = None
706706

707707

708+
class ToolAnnotations(BaseModel):
709+
"""
710+
Additional properties describing a Tool to clients.
711+
712+
NOTE: all properties in ToolAnnotations are **hints**.
713+
They are not guaranteed to provide a faithful description of
714+
tool behavior (including descriptive properties like `title`).
715+
716+
Clients should never make tool use decisions based on ToolAnnotations
717+
received from untrusted servers.
718+
"""
719+
720+
title: str | None = None
721+
"""A human-readable title for the tool."""
722+
723+
readOnlyHint: bool | None = None
724+
"""
725+
If true, the tool does not modify its environment.
726+
Default: false
727+
"""
728+
729+
destructiveHint: bool | None = None
730+
"""
731+
If true, the tool may perform destructive updates to its environment.
732+
If false, the tool performs only additive updates.
733+
(This property is meaningful only when `readOnlyHint == false`)
734+
Default: true
735+
"""
736+
737+
idempotentHint: bool | None = None
738+
"""
739+
If true, calling the tool repeatedly with the same arguments
740+
will have no additional effect on the its environment.
741+
(This property is meaningful only when `readOnlyHint == false`)
742+
Default: false
743+
"""
744+
745+
openWorldHint: bool | None = None
746+
"""
747+
If true, this tool may interact with an "open world" of external
748+
entities. If false, the tool's domain of interaction is closed.
749+
For example, the world of a web search tool is open, whereas that
750+
of a memory tool is not.
751+
Default: true
752+
"""
753+
model_config = ConfigDict(extra="allow")
754+
755+
708756
class Tool(BaseModel):
709757
"""Definition for a tool the client can call."""
710758

@@ -714,6 +762,8 @@ class Tool(BaseModel):
714762
"""A human-readable description of the tool."""
715763
inputSchema: dict[str, Any]
716764
"""A JSON Schema object defining the expected parameters for the tool."""
765+
annotations: ToolAnnotations | None = None
766+
"""Optional additional tool information."""
717767
model_config = ConfigDict(extra="allow")
718768

719769

tests/server/fastmcp/test_tool_manager.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from mcp.server.fastmcp.tools import ToolManager
1010
from mcp.server.session import ServerSessionT
1111
from mcp.shared.context import LifespanContextT
12+
from mcp.types import ToolAnnotations
1213

1314

1415
class TestAddTools:
@@ -321,3 +322,43 @@ def tool_with_context(x: int, ctx: Context) -> str:
321322
ctx = mcp.get_context()
322323
with pytest.raises(ToolError, match="Error executing tool tool_with_context"):
323324
await manager.call_tool("tool_with_context", {"x": 42}, context=ctx)
325+
326+
327+
class TestToolAnnotations:
328+
def test_tool_annotations(self):
329+
"""Test that tool annotations are correctly added to tools."""
330+
331+
def read_data(path: str) -> str:
332+
"""Read data from a file."""
333+
return f"Data from {path}"
334+
335+
annotations = ToolAnnotations(
336+
title="File Reader",
337+
readOnlyHint=True,
338+
openWorldHint=False,
339+
)
340+
341+
manager = ToolManager()
342+
tool = manager.add_tool(read_data, annotations=annotations)
343+
344+
assert tool.annotations is not None
345+
assert tool.annotations.title == "File Reader"
346+
assert tool.annotations.readOnlyHint is True
347+
assert tool.annotations.openWorldHint is False
348+
349+
@pytest.mark.anyio
350+
async def test_tool_annotations_in_fastmcp(self):
351+
"""Test that tool annotations are included in MCPTool conversion."""
352+
353+
app = FastMCP()
354+
355+
@app.tool(annotations=ToolAnnotations(title="Echo Tool", readOnlyHint=True))
356+
def echo(message: str) -> str:
357+
"""Echo a message back."""
358+
return message
359+
360+
tools = await app.list_tools()
361+
assert len(tools) == 1
362+
assert tools[0].annotations is not None
363+
assert tools[0].annotations.title == "Echo Tool"
364+
assert tools[0].annotations.readOnlyHint is True

0 commit comments

Comments
 (0)