Skip to content

Commit 9b5709a

Browse files
committed
Merge branch 'main' into praboud/auth
2 parents fa068dd + 1a330ac commit 9b5709a

File tree

14 files changed

+274
-31
lines changed

14 files changed

+274
-31
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/prompts/base.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Base classes for FastMCP prompts."""
22

33
import inspect
4-
import json
54
from collections.abc import Awaitable, Callable, Sequence
65
from typing import Any, Literal
76

@@ -155,7 +154,9 @@ async def render(self, arguments: dict[str, Any] | None = None) -> list[Message]
155154
content = TextContent(type="text", text=msg)
156155
messages.append(UserMessage(content=content))
157156
else:
158-
content = json.dumps(pydantic_core.to_jsonable_python(msg))
157+
content = pydantic_core.to_json(
158+
msg, fallback=str, indent=2
159+
).decode()
159160
messages.append(Message(role="user", content=content))
160161
except Exception:
161162
raise ValueError(

src/mcp/server/fastmcp/resources/types.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import anyio
1010
import anyio.to_thread
1111
import httpx
12-
import pydantic.json
12+
import pydantic
1313
import pydantic_core
1414
from pydantic import Field, ValidationInfo
1515

@@ -59,15 +59,12 @@ async def read(self) -> str | bytes:
5959
)
6060
if isinstance(result, Resource):
6161
return await result.read()
62-
if isinstance(result, bytes):
62+
elif isinstance(result, bytes):
6363
return result
64-
if isinstance(result, str):
64+
elif isinstance(result, str):
6565
return result
66-
try:
67-
return json.dumps(pydantic_core.to_jsonable_python(result))
68-
except (TypeError, pydantic_core.PydanticSerializationError):
69-
# If JSON serialization fails, try str()
70-
return str(result)
66+
else:
67+
return pydantic_core.to_json(result, fallback=str, indent=2).decode()
7168
except Exception as e:
7269
raise ValueError(f"Error reading resource {self.uri}: {e}")
7370

src/mcp/server/fastmcp/server.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from __future__ import annotations as _annotations
44

55
import inspect
6-
import json
76
import re
87
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
98
from contextlib import (
@@ -55,6 +54,7 @@
5554
GetPromptResult,
5655
ImageContent,
5756
TextContent,
57+
ToolAnnotations,
5858
)
5959
from mcp.types import Prompt as MCPPrompt
6060
from mcp.types import PromptArgument as MCPPromptArgument
@@ -210,6 +210,7 @@ async def list_tools(self) -> list[MCPTool]:
210210
name=info.name,
211211
description=info.description,
212212
inputSchema=info.parameters,
213+
annotations=info.annotations,
213214
)
214215
for info in tools
215216
]
@@ -278,6 +279,7 @@ def add_tool(
278279
fn: AnyFunction,
279280
name: str | None = None,
280281
description: str | None = None,
282+
annotations: ToolAnnotations | None = None,
281283
) -> None:
282284
"""Add a tool to the server.
283285
@@ -288,11 +290,17 @@ def add_tool(
288290
fn: The function to register as a tool
289291
name: Optional name for the tool (defaults to function name)
290292
description: Optional description of what the tool does
293+
annotations: Optional ToolAnnotations providing additional tool information
291294
"""
292-
self._tool_manager.add_tool(fn, name=name, description=description)
295+
self._tool_manager.add_tool(
296+
fn, name=name, description=description, annotations=annotations
297+
)
293298

294299
def tool(
295-
self, name: str | None = None, description: str | None = None
300+
self,
301+
name: str | None = None,
302+
description: str | None = None,
303+
annotations: ToolAnnotations | None = None,
296304
) -> Callable[[AnyFunction], AnyFunction]:
297305
"""Decorator to register a tool.
298306
@@ -303,6 +311,7 @@ def tool(
303311
Args:
304312
name: Optional name for the tool (defaults to function name)
305313
description: Optional description of what the tool does
314+
annotations: Optional ToolAnnotations providing additional tool information
306315
307316
Example:
308317
@server.tool()
@@ -327,7 +336,9 @@ async def async_tool(x: int, context: Context) -> str:
327336
)
328337

329338
def decorator(fn: AnyFunction) -> AnyFunction:
330-
self.add_tool(fn, name=name, description=description)
339+
self.add_tool(
340+
fn, name=name, description=description, annotations=annotations
341+
)
331342
return fn
332343

333344
return decorator
@@ -685,10 +696,7 @@ def _convert_to_content(
685696
return list(chain.from_iterable(_convert_to_content(item) for item in result)) # type: ignore[reportUnknownVariableType]
686697

687698
if not isinstance(result, str):
688-
try:
689-
result = json.dumps(pydantic_core.to_jsonable_python(result))
690-
except Exception:
691-
result = str(result)
699+
result = pydantic_core.to_json(result, fallback=str, indent=2).decode()
692700

693701
return [TextContent(type="text", text=result)]
694702

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/resources/test_function_resources.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ class MyModel(BaseModel):
100100
fn=lambda: MyModel(name="test"),
101101
)
102102
content = await resource.read()
103-
assert content == '{"name": "test"}'
103+
assert content == '{\n "name": "test"\n}'
104104

105105
@pytest.mark.anyio
106106
async def test_custom_type_conversion(self):

tests/server/fastmcp/resources/test_resource_template.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,4 +185,4 @@ def get_data(value: str) -> CustomData:
185185

186186
assert isinstance(resource, FunctionResource)
187187
content = await resource.read()
188-
assert content == "hello"
188+
assert content == '"hello"'

0 commit comments

Comments
 (0)