Jeremiah Lowin commited on
Commit
10aafd8
·
unverified ·
2 Parent(s): f59c3c3265bc8e

Merge pull request #12 from jlowin/context

Browse files

Add support for request context, progress, logging, etc.

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(result, default=pydantic.json.pydantic_encoder)
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 Any, Callable, Optional, Sequence, Union, Literal
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 get_logger, configure_logging
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
- result = await self._tool_manager.call_tool(name, arguments)
 
 
 
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(item, default=pydantic.json.pydantic_encoder),
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(value, indent=2, default=pydantic.json.pydantic_encoder),
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(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/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