Spaces:
Running
Running
Merge pull request #12 from jlowin/context
Browse filesAdd support for request context, progress, logging, etc.
- src/fastmcp/__init__.py +2 -2
- src/fastmcp/resources/types.py +3 -2
- src/fastmcp/server.py +197 -8
- src/fastmcp/tools.py +27 -4
- tests/resources/test_function_resources.py +15 -0
- tests/test_server.py +97 -2
src/fastmcp/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""FastMCP - A more ergonomic interface for MCP servers."""
|
| 2 |
|
| 3 |
-
from .server import FastMCP
|
| 4 |
from .utilities.types import Image
|
| 5 |
|
| 6 |
-
__all__ = ["FastMCP", "Image"]
|
|
|
|
| 1 |
"""FastMCP - A more ergonomic interface for MCP servers."""
|
| 2 |
|
| 3 |
+
from .server import FastMCP, Context
|
| 4 |
from .utilities.types import Image
|
| 5 |
|
| 6 |
+
__all__ = ["FastMCP", "Context", "Image"]
|
src/fastmcp/resources/types.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""Concrete resource implementations."""
|
| 2 |
|
|
|
|
| 3 |
import asyncio
|
| 4 |
import json
|
| 5 |
from pathlib import Path
|
|
@@ -58,8 +59,8 @@ class FunctionResource(Resource):
|
|
| 58 |
if isinstance(result, str):
|
| 59 |
return result
|
| 60 |
try:
|
| 61 |
-
return json.dumps(
|
| 62 |
-
except TypeError:
|
| 63 |
# If JSON serialization fails, try str()
|
| 64 |
return str(result)
|
| 65 |
except Exception as e:
|
|
|
|
| 1 |
"""Concrete resource implementations."""
|
| 2 |
|
| 3 |
+
import pydantic_core
|
| 4 |
import asyncio
|
| 5 |
import json
|
| 6 |
from pathlib import Path
|
|
|
|
| 59 |
if isinstance(result, str):
|
| 60 |
return result
|
| 61 |
try:
|
| 62 |
+
return json.dumps(pydantic_core.to_jsonable_python(result))
|
| 63 |
+
except (TypeError, pydantic_core.PydanticSerializationError):
|
| 64 |
# If JSON serialization fails, try str()
|
| 65 |
return str(result)
|
| 66 |
except Exception as e:
|
src/fastmcp/server.py
CHANGED
|
@@ -1,13 +1,20 @@
|
|
| 1 |
"""FastMCP - A more ergonomic interface for MCP servers."""
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import asyncio
|
| 4 |
import functools
|
| 5 |
import json
|
| 6 |
-
from typing import
|
| 7 |
import inspect
|
| 8 |
import re
|
| 9 |
|
| 10 |
-
import pydantic.json
|
| 11 |
from mcp.server import Server as MCPServer
|
| 12 |
from mcp.server.stdio import stdio_server
|
| 13 |
from mcp.server.sse import SseServerTransport
|
|
@@ -25,7 +32,7 @@ from fastmcp.exceptions import ResourceError
|
|
| 25 |
from fastmcp.resources import Resource, ResourceManager
|
| 26 |
from fastmcp.resources.types import FunctionResource
|
| 27 |
from fastmcp.tools import ToolManager
|
| 28 |
-
from fastmcp.utilities.logging import
|
| 29 |
from fastmcp.utilities.types import Image
|
| 30 |
|
| 31 |
logger = get_logger(__name__)
|
|
@@ -112,12 +119,22 @@ class FastMCP:
|
|
| 112 |
for info in tools
|
| 113 |
]
|
| 114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
async def call_tool(
|
| 116 |
self, name: str, arguments: dict
|
| 117 |
) -> Sequence[Union[TextContent, ImageContent]]:
|
| 118 |
"""Call a tool by name with arguments."""
|
| 119 |
try:
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
| 121 |
return _convert_to_content(result)
|
| 122 |
except Exception as e:
|
| 123 |
logger.error(f"Error calling tool {name}: {e}")
|
|
@@ -172,13 +189,45 @@ class FastMCP:
|
|
| 172 |
name: Optional[str] = None,
|
| 173 |
description: Optional[str] = None,
|
| 174 |
) -> None:
|
| 175 |
-
"""Add a tool to the server.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
self._tool_manager.add_tool(func, name=name, description=description)
|
| 177 |
|
| 178 |
def tool(
|
| 179 |
self, name: Optional[str] = None, description: Optional[str] = None
|
| 180 |
) -> Callable:
|
| 181 |
-
"""Decorator to register a tool.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
# Check if user passed function directly instead of calling decorator
|
| 183 |
if callable(name):
|
| 184 |
raise TypeError(
|
|
@@ -348,7 +397,7 @@ def _convert_to_content(value: Any) -> Sequence[Union[TextContent, ImageContent]
|
|
| 348 |
result.append(
|
| 349 |
TextContent(
|
| 350 |
type="text",
|
| 351 |
-
text=json.dumps(
|
| 352 |
)
|
| 353 |
)
|
| 354 |
return result
|
|
@@ -365,6 +414,146 @@ def _convert_to_content(value: Any) -> Sequence[Union[TextContent, ImageContent]
|
|
| 365 |
return [
|
| 366 |
TextContent(
|
| 367 |
type="text",
|
| 368 |
-
text=json.dumps(
|
| 369 |
)
|
| 370 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""FastMCP - A more ergonomic interface for MCP servers."""
|
| 2 |
|
| 3 |
+
import pydantic_core
|
| 4 |
+
from typing import Any, Literal, Optional, Union
|
| 5 |
+
|
| 6 |
+
from mcp.server import RequestContext
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
from pydantic.networks import AnyUrl
|
| 9 |
+
|
| 10 |
+
from fastmcp.utilities.logging import get_logger
|
| 11 |
import asyncio
|
| 12 |
import functools
|
| 13 |
import json
|
| 14 |
+
from typing import Callable, Sequence
|
| 15 |
import inspect
|
| 16 |
import re
|
| 17 |
|
|
|
|
| 18 |
from mcp.server import Server as MCPServer
|
| 19 |
from mcp.server.stdio import stdio_server
|
| 20 |
from mcp.server.sse import SseServerTransport
|
|
|
|
| 32 |
from fastmcp.resources import Resource, ResourceManager
|
| 33 |
from fastmcp.resources.types import FunctionResource
|
| 34 |
from fastmcp.tools import ToolManager
|
| 35 |
+
from fastmcp.utilities.logging import configure_logging
|
| 36 |
from fastmcp.utilities.types import Image
|
| 37 |
|
| 38 |
logger = get_logger(__name__)
|
|
|
|
| 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}")
|
|
|
|
| 189 |
name: Optional[str] = None,
|
| 190 |
description: Optional[str] = None,
|
| 191 |
) -> None:
|
| 192 |
+
"""Add a tool to the server.
|
| 193 |
+
|
| 194 |
+
The tool function can optionally request a Context object by adding a parameter
|
| 195 |
+
with the Context type annotation. See the @tool decorator for examples.
|
| 196 |
+
|
| 197 |
+
Args:
|
| 198 |
+
func: The function to register as a tool
|
| 199 |
+
name: Optional name for the tool (defaults to function name)
|
| 200 |
+
description: Optional description of what the tool does
|
| 201 |
+
"""
|
| 202 |
self._tool_manager.add_tool(func, name=name, description=description)
|
| 203 |
|
| 204 |
def tool(
|
| 205 |
self, name: Optional[str] = None, description: Optional[str] = None
|
| 206 |
) -> Callable:
|
| 207 |
+
"""Decorator to register a tool.
|
| 208 |
+
|
| 209 |
+
Tools can optionally request a Context object by adding a parameter with the Context type annotation.
|
| 210 |
+
The context provides access to MCP capabilities like logging, progress reporting, and resource access.
|
| 211 |
+
|
| 212 |
+
Args:
|
| 213 |
+
name: Optional name for the tool (defaults to function name)
|
| 214 |
+
description: Optional description of what the tool does
|
| 215 |
+
|
| 216 |
+
Example:
|
| 217 |
+
@server.tool()
|
| 218 |
+
def my_tool(x: int) -> str:
|
| 219 |
+
return str(x)
|
| 220 |
+
|
| 221 |
+
@server.tool()
|
| 222 |
+
def tool_with_context(x: int, ctx: Context) -> str:
|
| 223 |
+
ctx.info(f"Processing {x}")
|
| 224 |
+
return str(x)
|
| 225 |
+
|
| 226 |
+
@server.tool()
|
| 227 |
+
async def async_tool(x: int, context: Context) -> str:
|
| 228 |
+
await context.report_progress(50, 100)
|
| 229 |
+
return str(x)
|
| 230 |
+
"""
|
| 231 |
# Check if user passed function directly instead of calling decorator
|
| 232 |
if callable(name):
|
| 233 |
raise TypeError(
|
|
|
|
| 397 |
result.append(
|
| 398 |
TextContent(
|
| 399 |
type="text",
|
| 400 |
+
text=json.dumps(pydantic_core.to_jsonable_python(item)),
|
| 401 |
)
|
| 402 |
)
|
| 403 |
return result
|
|
|
|
| 414 |
return [
|
| 415 |
TextContent(
|
| 416 |
type="text",
|
| 417 |
+
text=json.dumps(pydantic_core.to_jsonable_python(value)),
|
| 418 |
)
|
| 419 |
]
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
class Context(BaseModel):
|
| 423 |
+
"""Context object providing access to MCP capabilities.
|
| 424 |
+
|
| 425 |
+
This provides a cleaner interface to MCP's RequestContext functionality.
|
| 426 |
+
It gets injected into tool and resource functions that request it via type hints.
|
| 427 |
+
|
| 428 |
+
To use context in a tool function, add a parameter with the Context type annotation:
|
| 429 |
+
|
| 430 |
+
```python
|
| 431 |
+
@server.tool()
|
| 432 |
+
def my_tool(x: int, ctx: Context) -> str:
|
| 433 |
+
# Log messages to the client
|
| 434 |
+
ctx.info(f"Processing {x}")
|
| 435 |
+
ctx.debug("Debug info")
|
| 436 |
+
ctx.warning("Warning message")
|
| 437 |
+
ctx.error("Error message")
|
| 438 |
+
|
| 439 |
+
# Report progress
|
| 440 |
+
ctx.report_progress(50, 100)
|
| 441 |
+
|
| 442 |
+
# Access resources
|
| 443 |
+
data = ctx.read_resource("resource://data")
|
| 444 |
+
|
| 445 |
+
# Get request info
|
| 446 |
+
request_id = ctx.request_id
|
| 447 |
+
client_id = ctx.client_id
|
| 448 |
+
|
| 449 |
+
return str(x)
|
| 450 |
+
```
|
| 451 |
+
|
| 452 |
+
The context parameter name can be anything as long as it's annotated with Context.
|
| 453 |
+
The context is optional - tools that don't need it can omit the parameter.
|
| 454 |
+
"""
|
| 455 |
+
|
| 456 |
+
_request_context: RequestContext
|
| 457 |
+
_fastmcp: FastMCP
|
| 458 |
+
|
| 459 |
+
def __init__(
|
| 460 |
+
self, *, request_context: RequestContext, fastmcp: FastMCP, **kwargs: Any
|
| 461 |
+
):
|
| 462 |
+
super().__init__(**kwargs)
|
| 463 |
+
self._request_context = request_context
|
| 464 |
+
self._fastmcp = fastmcp
|
| 465 |
+
|
| 466 |
+
@property
|
| 467 |
+
def fastmcp(self) -> FastMCP:
|
| 468 |
+
"""Access to the FastMCP server."""
|
| 469 |
+
return self._fastmcp
|
| 470 |
+
|
| 471 |
+
@property
|
| 472 |
+
def request_context(self) -> RequestContext:
|
| 473 |
+
"""Access to the underlying request context."""
|
| 474 |
+
return self._request_context
|
| 475 |
+
|
| 476 |
+
async def report_progress(
|
| 477 |
+
self, progress: float, total: Optional[float] = None
|
| 478 |
+
) -> None:
|
| 479 |
+
"""Report progress for the current operation.
|
| 480 |
+
|
| 481 |
+
Args:
|
| 482 |
+
progress: Current progress value e.g. 24
|
| 483 |
+
total: Optional total value e.g. 100
|
| 484 |
+
"""
|
| 485 |
+
|
| 486 |
+
progress_token = (
|
| 487 |
+
self.request_context.meta.progressToken
|
| 488 |
+
if self.request_context.meta
|
| 489 |
+
else None
|
| 490 |
+
)
|
| 491 |
+
|
| 492 |
+
if not progress_token:
|
| 493 |
+
return
|
| 494 |
+
|
| 495 |
+
await self.request_context.session.send_progress_notification(
|
| 496 |
+
progress_token=progress_token, progress=progress, total=total
|
| 497 |
+
)
|
| 498 |
+
|
| 499 |
+
async def read_resource(self, uri: Union[str, AnyUrl]) -> Union[str, bytes]:
|
| 500 |
+
"""Read a resource by URI.
|
| 501 |
+
|
| 502 |
+
Args:
|
| 503 |
+
uri: Resource URI to read
|
| 504 |
+
|
| 505 |
+
Returns:
|
| 506 |
+
The resource content as either text or bytes
|
| 507 |
+
"""
|
| 508 |
+
return await self._fastmcp.read_resource(uri)
|
| 509 |
+
|
| 510 |
+
def log(
|
| 511 |
+
self,
|
| 512 |
+
level: Literal["debug", "info", "warning", "error"],
|
| 513 |
+
message: str,
|
| 514 |
+
*,
|
| 515 |
+
logger_name: Optional[str] = None,
|
| 516 |
+
) -> None:
|
| 517 |
+
"""Send a log message to the client.
|
| 518 |
+
|
| 519 |
+
Args:
|
| 520 |
+
level: Log level (debug, info, warning, error)
|
| 521 |
+
message: Log message
|
| 522 |
+
logger_name: Optional logger name
|
| 523 |
+
**extra: Additional structured data to include
|
| 524 |
+
"""
|
| 525 |
+
self.request_context.session.send_log_message(
|
| 526 |
+
level=level, data=message, logger=logger_name
|
| 527 |
+
)
|
| 528 |
+
|
| 529 |
+
@property
|
| 530 |
+
def client_id(self) -> Optional[str]:
|
| 531 |
+
"""Get the client ID if available."""
|
| 532 |
+
return self.request_context.meta.clientId if self.request_context.meta else None
|
| 533 |
+
|
| 534 |
+
@property
|
| 535 |
+
def request_id(self) -> str:
|
| 536 |
+
"""Get the unique ID for this request."""
|
| 537 |
+
return self.request_context.request_id
|
| 538 |
+
|
| 539 |
+
@property
|
| 540 |
+
def session(self):
|
| 541 |
+
"""Access to the underlying session for advanced usage."""
|
| 542 |
+
return self.request_context.session
|
| 543 |
+
|
| 544 |
+
# Convenience methods for common log levels
|
| 545 |
+
def debug(self, message: str, **extra: Any) -> None:
|
| 546 |
+
"""Send a debug log message."""
|
| 547 |
+
self.log("debug", message, **extra)
|
| 548 |
+
|
| 549 |
+
def info(self, message: str, **extra: Any) -> None:
|
| 550 |
+
"""Send an info log message."""
|
| 551 |
+
self.log("info", message, **extra)
|
| 552 |
+
|
| 553 |
+
def warning(self, message: str, **extra: Any) -> None:
|
| 554 |
+
"""Send a warning log message."""
|
| 555 |
+
self.log("warning", message, **extra)
|
| 556 |
+
|
| 557 |
+
def error(self, message: str, **extra: Any) -> None:
|
| 558 |
+
"""Send an error log message."""
|
| 559 |
+
self.log("error", message, **extra)
|
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/resources/test_function_resources.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import pytest
|
| 2 |
from fastmcp.resources import FunctionResource
|
| 3 |
|
|
@@ -80,6 +81,20 @@ class TestFunctionResource:
|
|
| 80 |
with pytest.raises(ValueError, match="Error reading resource function://test"):
|
| 81 |
await resource.read()
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
async def test_custom_type_conversion(self):
|
| 84 |
"""Test handling of custom types."""
|
| 85 |
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
import pytest
|
| 3 |
from fastmcp.resources import FunctionResource
|
| 4 |
|
|
|
|
| 81 |
with pytest.raises(ValueError, match="Error reading resource function://test"):
|
| 82 |
await resource.read()
|
| 83 |
|
| 84 |
+
async def test_basemodel_conversion(self):
|
| 85 |
+
"""Test handling of BaseModel types."""
|
| 86 |
+
|
| 87 |
+
class MyModel(BaseModel):
|
| 88 |
+
name: str
|
| 89 |
+
|
| 90 |
+
resource = FunctionResource(
|
| 91 |
+
uri="function://test",
|
| 92 |
+
name="test",
|
| 93 |
+
func=lambda: MyModel(name="test"),
|
| 94 |
+
)
|
| 95 |
+
content = await resource.read()
|
| 96 |
+
assert content == '{"name": "test"}'
|
| 97 |
+
|
| 98 |
async def test_custom_type_conversion(self):
|
| 99 |
"""Test handling of custom types."""
|
| 100 |
|
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
|