Spaces:
Running
Running
Merge pull request #13 from jlowin/context
Browse files- src/fastmcp/server.py +16 -4
- src/fastmcp/tools.py +1 -1
- tests/test_tool_manager.py +82 -0
src/fastmcp/server.py
CHANGED
|
@@ -119,12 +119,16 @@ class FastMCP:
|
|
| 119 |
for info in tools
|
| 120 |
]
|
| 121 |
|
| 122 |
-
def get_context(self) ->
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
try:
|
| 124 |
request_context = self._mcp_server.request_context
|
| 125 |
-
return Context(request_context=request_context, fastmcp=self)
|
| 126 |
except LookupError:
|
| 127 |
-
|
|
|
|
| 128 |
|
| 129 |
async def call_tool(
|
| 130 |
self, name: str, arguments: dict
|
|
@@ -457,7 +461,11 @@ class Context(BaseModel):
|
|
| 457 |
_fastmcp: FastMCP
|
| 458 |
|
| 459 |
def __init__(
|
| 460 |
-
self,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
):
|
| 462 |
super().__init__(**kwargs)
|
| 463 |
self._request_context = request_context
|
|
@@ -466,11 +474,15 @@ class Context(BaseModel):
|
|
| 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(
|
|
|
|
| 119 |
for info in tools
|
| 120 |
]
|
| 121 |
|
| 122 |
+
def get_context(self) -> "Context":
|
| 123 |
+
"""
|
| 124 |
+
Returns a Context object. Note that the context will only be valid
|
| 125 |
+
during a request; outside a request, most methods will error.
|
| 126 |
+
"""
|
| 127 |
try:
|
| 128 |
request_context = self._mcp_server.request_context
|
|
|
|
| 129 |
except LookupError:
|
| 130 |
+
request_context = None
|
| 131 |
+
return Context(request_context=request_context, fastmcp=self)
|
| 132 |
|
| 133 |
async def call_tool(
|
| 134 |
self, name: str, arguments: dict
|
|
|
|
| 461 |
_fastmcp: FastMCP
|
| 462 |
|
| 463 |
def __init__(
|
| 464 |
+
self,
|
| 465 |
+
*,
|
| 466 |
+
request_context: RequestContext = None,
|
| 467 |
+
fastmcp: FastMCP = None,
|
| 468 |
+
**kwargs: Any,
|
| 469 |
):
|
| 470 |
super().__init__(**kwargs)
|
| 471 |
self._request_context = request_context
|
|
|
|
| 474 |
@property
|
| 475 |
def fastmcp(self) -> FastMCP:
|
| 476 |
"""Access to the FastMCP server."""
|
| 477 |
+
if self._fastmcp is None:
|
| 478 |
+
raise ValueError("Context is not available outside of a request")
|
| 479 |
return self._fastmcp
|
| 480 |
|
| 481 |
@property
|
| 482 |
def request_context(self) -> RequestContext:
|
| 483 |
"""Access to the underlying request context."""
|
| 484 |
+
if self._request_context is None:
|
| 485 |
+
raise ValueError("Context is not available outside of a request")
|
| 486 |
return self._request_context
|
| 487 |
|
| 488 |
async def report_progress(
|
src/fastmcp/tools.py
CHANGED
|
@@ -71,7 +71,7 @@ class Tool(BaseModel):
|
|
| 71 |
"""Run the tool with arguments."""
|
| 72 |
try:
|
| 73 |
# Inject context if needed
|
| 74 |
-
if self.context_kwarg
|
| 75 |
arguments[self.context_kwarg] = context
|
| 76 |
|
| 77 |
# Call function with proper async handling
|
|
|
|
| 71 |
"""Run the tool with arguments."""
|
| 72 |
try:
|
| 73 |
# Inject context if needed
|
| 74 |
+
if self.context_kwarg:
|
| 75 |
arguments[self.context_kwarg] = context
|
| 76 |
|
| 77 |
# Call function with proper async handling
|
tests/test_tool_manager.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import logging
|
| 2 |
import pytest
|
| 3 |
from pydantic import BaseModel
|
|
|
|
| 4 |
|
| 5 |
from fastmcp.exceptions import ToolError
|
| 6 |
from fastmcp.tools import ToolManager
|
|
@@ -153,3 +154,84 @@ class TestCallTools:
|
|
| 153 |
manager = ToolManager()
|
| 154 |
with pytest.raises(ToolError):
|
| 155 |
await manager.call_tool("unknown", {"a": 1})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import logging
|
| 2 |
import pytest
|
| 3 |
from pydantic import BaseModel
|
| 4 |
+
from typing import Optional
|
| 5 |
|
| 6 |
from fastmcp.exceptions import ToolError
|
| 7 |
from fastmcp.tools import ToolManager
|
|
|
|
| 154 |
manager = ToolManager()
|
| 155 |
with pytest.raises(ToolError):
|
| 156 |
await manager.call_tool("unknown", {"a": 1})
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
class TestContextHandling:
|
| 160 |
+
"""Test context handling in the tool manager."""
|
| 161 |
+
|
| 162 |
+
def test_context_parameter_detection(self):
|
| 163 |
+
"""Test that context parameters are properly detected in Tool.from_function()."""
|
| 164 |
+
from fastmcp import Context
|
| 165 |
+
|
| 166 |
+
def tool_with_context(x: int, ctx: Context) -> str:
|
| 167 |
+
return str(x)
|
| 168 |
+
|
| 169 |
+
manager = ToolManager()
|
| 170 |
+
tool = manager.add_tool(tool_with_context)
|
| 171 |
+
assert tool.context_kwarg == "ctx"
|
| 172 |
+
|
| 173 |
+
def tool_without_context(x: int) -> str:
|
| 174 |
+
return str(x)
|
| 175 |
+
|
| 176 |
+
tool = manager.add_tool(tool_without_context)
|
| 177 |
+
assert tool.context_kwarg is None
|
| 178 |
+
|
| 179 |
+
async def test_context_injection(self):
|
| 180 |
+
"""Test that context is properly injected during tool execution."""
|
| 181 |
+
from fastmcp import Context, FastMCP
|
| 182 |
+
|
| 183 |
+
def tool_with_context(x: int, ctx: Context) -> str:
|
| 184 |
+
assert isinstance(ctx, Context)
|
| 185 |
+
return str(x)
|
| 186 |
+
|
| 187 |
+
manager = ToolManager()
|
| 188 |
+
tool = manager.add_tool(tool_with_context)
|
| 189 |
+
|
| 190 |
+
mcp = FastMCP()
|
| 191 |
+
ctx = mcp.get_context()
|
| 192 |
+
result = await manager.call_tool("tool_with_context", {"x": 42}, context=ctx)
|
| 193 |
+
assert result == "42"
|
| 194 |
+
|
| 195 |
+
async def test_context_injection_async(self):
|
| 196 |
+
"""Test that context is properly injected in async tools."""
|
| 197 |
+
from fastmcp import Context, FastMCP
|
| 198 |
+
|
| 199 |
+
async def async_tool(x: int, ctx: Context) -> str:
|
| 200 |
+
assert isinstance(ctx, Context)
|
| 201 |
+
return str(x)
|
| 202 |
+
|
| 203 |
+
manager = ToolManager()
|
| 204 |
+
tool = manager.add_tool(async_tool)
|
| 205 |
+
|
| 206 |
+
mcp = FastMCP()
|
| 207 |
+
ctx = mcp.get_context()
|
| 208 |
+
result = await manager.call_tool("async_tool", {"x": 42}, context=ctx)
|
| 209 |
+
assert result == "42"
|
| 210 |
+
|
| 211 |
+
async def test_context_optional(self):
|
| 212 |
+
"""Test that context is optional when calling tools."""
|
| 213 |
+
from fastmcp import Context
|
| 214 |
+
|
| 215 |
+
def tool_with_context(x: int, ctx: Optional[Context] = None) -> str:
|
| 216 |
+
return str(x)
|
| 217 |
+
|
| 218 |
+
manager = ToolManager()
|
| 219 |
+
tool = manager.add_tool(tool_with_context)
|
| 220 |
+
# Should not raise an error when context is not provided
|
| 221 |
+
result = await manager.call_tool("tool_with_context", {"x": 42})
|
| 222 |
+
assert result == "42"
|
| 223 |
+
|
| 224 |
+
async def test_context_error_handling(self):
|
| 225 |
+
"""Test error handling when context injection fails."""
|
| 226 |
+
from fastmcp import Context, FastMCP
|
| 227 |
+
|
| 228 |
+
def tool_with_context(x: int, ctx: Context) -> str:
|
| 229 |
+
raise ValueError("Test error")
|
| 230 |
+
|
| 231 |
+
manager = ToolManager()
|
| 232 |
+
tool = manager.add_tool(tool_with_context)
|
| 233 |
+
|
| 234 |
+
mcp = FastMCP()
|
| 235 |
+
ctx = mcp.get_context()
|
| 236 |
+
with pytest.raises(ToolError, match="Error executing tool tool_with_context"):
|
| 237 |
+
await manager.call_tool("tool_with_context", {"x": 42}, context=ctx)
|