Jeremiah Lowin commited on
Commit
be7ad70
·
1 Parent(s): 22dfaa5

Add context calls

Browse files
Files changed (3) hide show
  1. src/fastmcp/server.py +37 -15
  2. src/fastmcp/tools.py +27 -4
  3. 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
- result = await self._tool_manager.call_tool(name, arguments)
 
 
 
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
- fastmcp: FastMCP
 
 
 
 
 
 
 
389
 
390
- model_config: dict = dict(arbitrary_types_allowed=True)
 
 
 
 
 
 
 
 
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._request_context.meta.progressToken
404
- if self._request_context.meta
405
  else None
406
  )
407
 
408
  if not progress_token:
409
  return
410
 
411
- await self._request_context.session.send_progress_notification(
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.fastmcp.read_resource(uri)
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._request_context.session.send_log_message(
443
- level=level, data=message, logger=logger_name, extra=extra
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._request_context.request_id
457
 
458
  @property
459
  def session(self):
460
  """Access to the underlying session for advanced usage."""
461
- return self._request_context.session
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(self, name: str, arguments: dict) -> Any:
 
 
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