Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
be7ad70
1
Parent(s): 22dfaa5
Add context calls
Browse files- src/fastmcp/server.py +37 -15
- src/fastmcp/tools.py +27 -4
- tests/test_server.py +97 -2
src/fastmcp/server.py
CHANGED
|
@@ -119,12 +119,22 @@ class FastMCP:
|
|
| 119 |
for info in tools
|
| 120 |
]
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
async def call_tool(
|
| 123 |
self, name: str, arguments: dict
|
| 124 |
) -> Sequence[Union[TextContent, ImageContent]]:
|
| 125 |
"""Call a tool by name with arguments."""
|
| 126 |
try:
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
| 128 |
return _convert_to_content(result)
|
| 129 |
except Exception as e:
|
| 130 |
logger.error(f"Error calling tool {name}: {e}")
|
|
@@ -385,9 +395,24 @@ class Context(BaseModel):
|
|
| 385 |
"""
|
| 386 |
|
| 387 |
_request_context: RequestContext
|
| 388 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
|
| 390 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
|
| 392 |
async def report_progress(
|
| 393 |
self, progress: float, total: Optional[float] = None
|
|
@@ -400,15 +425,15 @@ class Context(BaseModel):
|
|
| 400 |
"""
|
| 401 |
|
| 402 |
progress_token = (
|
| 403 |
-
self.
|
| 404 |
-
if self.
|
| 405 |
else None
|
| 406 |
)
|
| 407 |
|
| 408 |
if not progress_token:
|
| 409 |
return
|
| 410 |
|
| 411 |
-
await self.
|
| 412 |
progress_token=progress_token, progress=progress, total=total
|
| 413 |
)
|
| 414 |
|
|
@@ -421,7 +446,7 @@ class Context(BaseModel):
|
|
| 421 |
Returns:
|
| 422 |
The resource content as either text or bytes
|
| 423 |
"""
|
| 424 |
-
return await self.
|
| 425 |
|
| 426 |
def log(
|
| 427 |
self,
|
|
@@ -429,7 +454,6 @@ class Context(BaseModel):
|
|
| 429 |
message: str,
|
| 430 |
*,
|
| 431 |
logger_name: Optional[str] = None,
|
| 432 |
-
**extra: Any,
|
| 433 |
) -> None:
|
| 434 |
"""Send a log message to the client.
|
| 435 |
|
|
@@ -439,26 +463,24 @@ class Context(BaseModel):
|
|
| 439 |
logger_name: Optional logger name
|
| 440 |
**extra: Additional structured data to include
|
| 441 |
"""
|
| 442 |
-
self.
|
| 443 |
-
level=level, data=message, logger=logger_name
|
| 444 |
)
|
| 445 |
|
| 446 |
@property
|
| 447 |
def client_id(self) -> Optional[str]:
|
| 448 |
"""Get the client ID if available."""
|
| 449 |
-
return
|
| 450 |
-
self._request_context.meta.clientId if self._request_context.meta else None
|
| 451 |
-
)
|
| 452 |
|
| 453 |
@property
|
| 454 |
def request_id(self) -> str:
|
| 455 |
"""Get the unique ID for this request."""
|
| 456 |
-
return self.
|
| 457 |
|
| 458 |
@property
|
| 459 |
def session(self):
|
| 460 |
"""Access to the underlying session for advanced usage."""
|
| 461 |
-
return self.
|
| 462 |
|
| 463 |
# Convenience methods for common log levels
|
| 464 |
def debug(self, message: str, **extra: Any) -> None:
|
|
|
|
| 119 |
for info in tools
|
| 120 |
]
|
| 121 |
|
| 122 |
+
def get_context(self) -> Optional["Context"]:
|
| 123 |
+
try:
|
| 124 |
+
request_context = self._mcp_server.request_context
|
| 125 |
+
return Context(request_context=request_context, fastmcp=self)
|
| 126 |
+
except LookupError:
|
| 127 |
+
return None
|
| 128 |
+
|
| 129 |
async def call_tool(
|
| 130 |
self, name: str, arguments: dict
|
| 131 |
) -> Sequence[Union[TextContent, ImageContent]]:
|
| 132 |
"""Call a tool by name with arguments."""
|
| 133 |
try:
|
| 134 |
+
context = self.get_context()
|
| 135 |
+
result = await self._tool_manager.call_tool(
|
| 136 |
+
name, arguments, context=context
|
| 137 |
+
)
|
| 138 |
return _convert_to_content(result)
|
| 139 |
except Exception as e:
|
| 140 |
logger.error(f"Error calling tool {name}: {e}")
|
|
|
|
| 395 |
"""
|
| 396 |
|
| 397 |
_request_context: RequestContext
|
| 398 |
+
_fastmcp: FastMCP
|
| 399 |
+
|
| 400 |
+
def __init__(
|
| 401 |
+
self, *, request_context: RequestContext, fastmcp: FastMCP, **kwargs: Any
|
| 402 |
+
):
|
| 403 |
+
super().__init__(**kwargs)
|
| 404 |
+
self._request_context = request_context
|
| 405 |
+
self._fastmcp = fastmcp
|
| 406 |
|
| 407 |
+
@property
|
| 408 |
+
def fastmcp(self) -> FastMCP:
|
| 409 |
+
"""Access to the FastMCP server."""
|
| 410 |
+
return self._fastmcp
|
| 411 |
+
|
| 412 |
+
@property
|
| 413 |
+
def request_context(self) -> RequestContext:
|
| 414 |
+
"""Access to the underlying request context."""
|
| 415 |
+
return self._request_context
|
| 416 |
|
| 417 |
async def report_progress(
|
| 418 |
self, progress: float, total: Optional[float] = None
|
|
|
|
| 425 |
"""
|
| 426 |
|
| 427 |
progress_token = (
|
| 428 |
+
self.request_context.meta.progressToken
|
| 429 |
+
if self.request_context.meta
|
| 430 |
else None
|
| 431 |
)
|
| 432 |
|
| 433 |
if not progress_token:
|
| 434 |
return
|
| 435 |
|
| 436 |
+
await self.request_context.session.send_progress_notification(
|
| 437 |
progress_token=progress_token, progress=progress, total=total
|
| 438 |
)
|
| 439 |
|
|
|
|
| 446 |
Returns:
|
| 447 |
The resource content as either text or bytes
|
| 448 |
"""
|
| 449 |
+
return await self._fastmcp.read_resource(uri)
|
| 450 |
|
| 451 |
def log(
|
| 452 |
self,
|
|
|
|
| 454 |
message: str,
|
| 455 |
*,
|
| 456 |
logger_name: Optional[str] = None,
|
|
|
|
| 457 |
) -> None:
|
| 458 |
"""Send a log message to the client.
|
| 459 |
|
|
|
|
| 463 |
logger_name: Optional logger name
|
| 464 |
**extra: Additional structured data to include
|
| 465 |
"""
|
| 466 |
+
self.request_context.session.send_log_message(
|
| 467 |
+
level=level, data=message, logger=logger_name
|
| 468 |
)
|
| 469 |
|
| 470 |
@property
|
| 471 |
def client_id(self) -> Optional[str]:
|
| 472 |
"""Get the client ID if available."""
|
| 473 |
+
return self.request_context.meta.clientId if self.request_context.meta else None
|
|
|
|
|
|
|
| 474 |
|
| 475 |
@property
|
| 476 |
def request_id(self) -> str:
|
| 477 |
"""Get the unique ID for this request."""
|
| 478 |
+
return self.request_context.request_id
|
| 479 |
|
| 480 |
@property
|
| 481 |
def session(self):
|
| 482 |
"""Access to the underlying session for advanced usage."""
|
| 483 |
+
return self.request_context.session
|
| 484 |
|
| 485 |
# Convenience methods for common log levels
|
| 486 |
def debug(self, message: str, **extra: Any) -> None:
|
src/fastmcp/tools.py
CHANGED
|
@@ -1,12 +1,16 @@
|
|
| 1 |
"""Tool management for FastMCP."""
|
| 2 |
|
| 3 |
import inspect
|
| 4 |
-
from typing import Any, Callable, Dict, Optional
|
| 5 |
|
| 6 |
from pydantic import BaseModel, Field, TypeAdapter, validate_call
|
| 7 |
|
| 8 |
from .exceptions import ToolError
|
| 9 |
from .utilities.logging import get_logger
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
logger = get_logger(__name__)
|
| 12 |
|
|
@@ -19,6 +23,9 @@ class Tool(BaseModel):
|
|
| 19 |
description: str = Field(description="Description of what the tool does")
|
| 20 |
parameters: dict = Field(description="JSON schema for tool parameters")
|
| 21 |
is_async: bool = Field(description="Whether the tool is async")
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
@classmethod
|
| 24 |
def from_function(
|
|
@@ -26,6 +33,7 @@ class Tool(BaseModel):
|
|
| 26 |
func: Callable,
|
| 27 |
name: Optional[str] = None,
|
| 28 |
description: Optional[str] = None,
|
|
|
|
| 29 |
) -> "Tool":
|
| 30 |
"""Create a Tool from a function."""
|
| 31 |
func_name = name or func.__name__
|
|
@@ -39,6 +47,14 @@ class Tool(BaseModel):
|
|
| 39 |
# Get schema from TypeAdapter - will fail if function isn't properly typed
|
| 40 |
parameters = TypeAdapter(func).json_schema()
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
# ensure the arguments are properly cast
|
| 43 |
func = validate_call(func)
|
| 44 |
|
|
@@ -48,11 +64,16 @@ class Tool(BaseModel):
|
|
| 48 |
description=func_doc,
|
| 49 |
parameters=parameters,
|
| 50 |
is_async=is_async,
|
|
|
|
| 51 |
)
|
| 52 |
|
| 53 |
-
async def run(self, arguments: dict) -> Any:
|
| 54 |
"""Run the tool with arguments."""
|
| 55 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
# Call function with proper async handling
|
| 57 |
if self.is_async:
|
| 58 |
return await self.func(**arguments)
|
|
@@ -92,10 +113,12 @@ class ToolManager:
|
|
| 92 |
self._tools[tool.name] = tool
|
| 93 |
return tool
|
| 94 |
|
| 95 |
-
async def call_tool(
|
|
|
|
|
|
|
| 96 |
"""Call a tool by name with arguments."""
|
| 97 |
tool = self.get_tool(name)
|
| 98 |
if not tool:
|
| 99 |
raise ToolError(f"Unknown tool: {name}")
|
| 100 |
|
| 101 |
-
return await tool.run(arguments)
|
|
|
|
| 1 |
"""Tool management for FastMCP."""
|
| 2 |
|
| 3 |
import inspect
|
| 4 |
+
from typing import Any, Callable, Dict, Optional, TYPE_CHECKING
|
| 5 |
|
| 6 |
from pydantic import BaseModel, Field, TypeAdapter, validate_call
|
| 7 |
|
| 8 |
from .exceptions import ToolError
|
| 9 |
from .utilities.logging import get_logger
|
| 10 |
+
import fastmcp
|
| 11 |
+
|
| 12 |
+
if TYPE_CHECKING:
|
| 13 |
+
from fastmcp.server import Context
|
| 14 |
|
| 15 |
logger = get_logger(__name__)
|
| 16 |
|
|
|
|
| 23 |
description: str = Field(description="Description of what the tool does")
|
| 24 |
parameters: dict = Field(description="JSON schema for tool parameters")
|
| 25 |
is_async: bool = Field(description="Whether the tool is async")
|
| 26 |
+
context_kwarg: Optional[str] = Field(
|
| 27 |
+
None, description="Name of the kwarg that should receive context"
|
| 28 |
+
)
|
| 29 |
|
| 30 |
@classmethod
|
| 31 |
def from_function(
|
|
|
|
| 33 |
func: Callable,
|
| 34 |
name: Optional[str] = None,
|
| 35 |
description: Optional[str] = None,
|
| 36 |
+
context_kwarg: Optional[str] = None,
|
| 37 |
) -> "Tool":
|
| 38 |
"""Create a Tool from a function."""
|
| 39 |
func_name = name or func.__name__
|
|
|
|
| 47 |
# Get schema from TypeAdapter - will fail if function isn't properly typed
|
| 48 |
parameters = TypeAdapter(func).json_schema()
|
| 49 |
|
| 50 |
+
# Find context parameter if it exists
|
| 51 |
+
if context_kwarg is None:
|
| 52 |
+
sig = inspect.signature(func)
|
| 53 |
+
for param_name, param in sig.parameters.items():
|
| 54 |
+
if param.annotation is fastmcp.Context:
|
| 55 |
+
context_kwarg = param_name
|
| 56 |
+
break
|
| 57 |
+
|
| 58 |
# ensure the arguments are properly cast
|
| 59 |
func = validate_call(func)
|
| 60 |
|
|
|
|
| 64 |
description=func_doc,
|
| 65 |
parameters=parameters,
|
| 66 |
is_async=is_async,
|
| 67 |
+
context_kwarg=context_kwarg,
|
| 68 |
)
|
| 69 |
|
| 70 |
+
async def run(self, arguments: dict, context: Optional["Context"] = None) -> Any:
|
| 71 |
"""Run the tool with arguments."""
|
| 72 |
try:
|
| 73 |
+
# Inject context if needed
|
| 74 |
+
if self.context_kwarg and context:
|
| 75 |
+
arguments[self.context_kwarg] = context
|
| 76 |
+
|
| 77 |
# Call function with proper async handling
|
| 78 |
if self.is_async:
|
| 79 |
return await self.func(**arguments)
|
|
|
|
| 113 |
self._tools[tool.name] = tool
|
| 114 |
return tool
|
| 115 |
|
| 116 |
+
async def call_tool(
|
| 117 |
+
self, name: str, arguments: dict, context: Optional["Context"] = None
|
| 118 |
+
) -> Any:
|
| 119 |
"""Call a tool by name with arguments."""
|
| 120 |
tool = self.get_tool(name)
|
| 121 |
if not tool:
|
| 122 |
raise ToolError(f"Unknown tool: {name}")
|
| 123 |
|
| 124 |
+
return await tool.run(arguments, context=context)
|
tests/test_server.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
from mcp.shared.memory import (
|
| 2 |
create_connected_server_and_client_session as client_session,
|
| 3 |
)
|
| 4 |
-
from fastmcp import FastMCP
|
| 5 |
from fastmcp.resources import FileResource, FunctionResource
|
| 6 |
from fastmcp.utilities.types import Image
|
| 7 |
from mcp.types import TextContent, ImageContent
|
|
@@ -9,7 +9,10 @@ import pytest
|
|
| 9 |
from pydantic import BaseModel
|
| 10 |
from pathlib import Path
|
| 11 |
import base64
|
| 12 |
-
from typing import Union
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
class TestServer:
|
|
@@ -368,3 +371,95 @@ class TestServerResourceTemplates:
|
|
| 368 |
assert isinstance(resource, FunctionResource)
|
| 369 |
result = await resource.read()
|
| 370 |
assert result == "Data for test"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from mcp.shared.memory import (
|
| 2 |
create_connected_server_and_client_session as client_session,
|
| 3 |
)
|
| 4 |
+
from fastmcp import FastMCP, Context
|
| 5 |
from fastmcp.resources import FileResource, FunctionResource
|
| 6 |
from fastmcp.utilities.types import Image
|
| 7 |
from mcp.types import TextContent, ImageContent
|
|
|
|
| 9 |
from pydantic import BaseModel
|
| 10 |
from pathlib import Path
|
| 11 |
import base64
|
| 12 |
+
from typing import Union, TYPE_CHECKING
|
| 13 |
+
|
| 14 |
+
if TYPE_CHECKING:
|
| 15 |
+
from fastmcp import Context
|
| 16 |
|
| 17 |
|
| 18 |
class TestServer:
|
|
|
|
| 371 |
assert isinstance(resource, FunctionResource)
|
| 372 |
result = await resource.read()
|
| 373 |
assert result == "Data for test"
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
class TestContextInjection:
|
| 377 |
+
"""Test context injection in tools."""
|
| 378 |
+
|
| 379 |
+
async def test_context_detection(self):
|
| 380 |
+
"""Test that context parameters are properly detected."""
|
| 381 |
+
mcp = FastMCP()
|
| 382 |
+
|
| 383 |
+
def tool_with_context(x: int, ctx: Context) -> str:
|
| 384 |
+
return f"Request {ctx.request_id}: {x}"
|
| 385 |
+
|
| 386 |
+
tool = mcp._tool_manager.add_tool(tool_with_context)
|
| 387 |
+
assert tool.context_kwarg == "ctx"
|
| 388 |
+
|
| 389 |
+
async def test_context_injection(self):
|
| 390 |
+
"""Test that context is properly injected into tool calls."""
|
| 391 |
+
mcp = FastMCP()
|
| 392 |
+
|
| 393 |
+
def tool_with_context(x: int, ctx: Context) -> str:
|
| 394 |
+
assert ctx.request_id is not None
|
| 395 |
+
return f"Request {ctx.request_id}: {x}"
|
| 396 |
+
|
| 397 |
+
mcp.add_tool(tool_with_context)
|
| 398 |
+
async with client_session(mcp._mcp_server) as client:
|
| 399 |
+
result = await client.call_tool("tool_with_context", {"x": 42})
|
| 400 |
+
assert len(result.content) == 1
|
| 401 |
+
assert "Request" in result.content[0].text
|
| 402 |
+
assert "42" in result.content[0].text
|
| 403 |
+
|
| 404 |
+
async def test_async_context(self):
|
| 405 |
+
"""Test that context works in async functions."""
|
| 406 |
+
mcp = FastMCP()
|
| 407 |
+
|
| 408 |
+
async def async_tool(x: int, ctx: Context) -> str:
|
| 409 |
+
assert ctx.request_id is not None
|
| 410 |
+
return f"Async request {ctx.request_id}: {x}"
|
| 411 |
+
|
| 412 |
+
mcp.add_tool(async_tool)
|
| 413 |
+
async with client_session(mcp._mcp_server) as client:
|
| 414 |
+
result = await client.call_tool("async_tool", {"x": 42})
|
| 415 |
+
assert len(result.content) == 1
|
| 416 |
+
assert "Async request" in result.content[0].text
|
| 417 |
+
assert "42" in result.content[0].text
|
| 418 |
+
|
| 419 |
+
async def test_context_logging(self):
|
| 420 |
+
"""Test that context logging methods work."""
|
| 421 |
+
mcp = FastMCP()
|
| 422 |
+
|
| 423 |
+
def logging_tool(msg: str, ctx: Context) -> str:
|
| 424 |
+
ctx.debug("Debug message")
|
| 425 |
+
ctx.info("Info message")
|
| 426 |
+
ctx.warning("Warning message")
|
| 427 |
+
ctx.error("Error message")
|
| 428 |
+
return f"Logged messages for {msg}"
|
| 429 |
+
|
| 430 |
+
mcp.add_tool(logging_tool)
|
| 431 |
+
async with client_session(mcp._mcp_server) as client:
|
| 432 |
+
result = await client.call_tool("logging_tool", {"msg": "test"})
|
| 433 |
+
assert len(result.content) == 1
|
| 434 |
+
assert "Logged messages for test" in result.content[0].text
|
| 435 |
+
|
| 436 |
+
async def test_optional_context(self):
|
| 437 |
+
"""Test that context is optional."""
|
| 438 |
+
mcp = FastMCP()
|
| 439 |
+
|
| 440 |
+
def no_context(x: int) -> int:
|
| 441 |
+
return x * 2
|
| 442 |
+
|
| 443 |
+
mcp.add_tool(no_context)
|
| 444 |
+
async with client_session(mcp._mcp_server) as client:
|
| 445 |
+
result = await client.call_tool("no_context", {"x": 21})
|
| 446 |
+
assert len(result.content) == 1
|
| 447 |
+
assert result.content[0].text == "42"
|
| 448 |
+
|
| 449 |
+
async def test_context_resource_access(self):
|
| 450 |
+
"""Test that context can access resources."""
|
| 451 |
+
mcp = FastMCP()
|
| 452 |
+
|
| 453 |
+
@mcp.resource("test://data")
|
| 454 |
+
def test_resource() -> str:
|
| 455 |
+
return "resource data"
|
| 456 |
+
|
| 457 |
+
@mcp.tool()
|
| 458 |
+
async def tool_with_resource(ctx: Context) -> str:
|
| 459 |
+
data = await ctx.read_resource("test://data")
|
| 460 |
+
return f"Read resource: {data}"
|
| 461 |
+
|
| 462 |
+
async with client_session(mcp._mcp_server) as client:
|
| 463 |
+
result = await client.call_tool("tool_with_resource", {})
|
| 464 |
+
assert len(result.content) == 1
|
| 465 |
+
assert "Read resource: resource data" in result.content[0].text
|