Jeremiah Lowin commited on
Commit
ff141a2
·
unverified ·
2 Parent(s): e947e9bd51028d

Merge branch 'main' into use-stderr-for-logs

Browse files
.github/workflows/{lint.yml → run-static.yml} RENAMED
@@ -28,3 +28,9 @@ jobs:
28
  python-version: "3.12"
29
  - name: Run pre-commit
30
  uses: pre-commit/action@v3.0.1
 
 
 
 
 
 
 
28
  python-version: "3.12"
29
  - name: Run pre-commit
30
  uses: pre-commit/action@v3.0.1
31
+ - name: Install dependencies
32
+ run: |
33
+ python -m pip install --upgrade pip
34
+ pip install ".[tests]"
35
+ - name: Run pyright
36
+ run: pyright src tests
pyproject.toml CHANGED
@@ -25,6 +25,7 @@ build-backend = "hatchling.build"
25
  [project.optional-dependencies]
26
  tests = [
27
  "pre-commit",
 
28
  "pytest>=8.3.3",
29
  "pytest-asyncio>=0.23.5",
30
  "pytest-flakefinder",
@@ -39,3 +40,15 @@ asyncio_default_fixture_loop_scope = "session"
39
 
40
  [tool.hatch.version]
41
  source = "vcs"
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  [project.optional-dependencies]
26
  tests = [
27
  "pre-commit",
28
+ "pyright>=1.1.389",
29
  "pytest>=8.3.3",
30
  "pytest-asyncio>=0.23.5",
31
  "pytest-flakefinder",
 
40
 
41
  [tool.hatch.version]
42
  source = "vcs"
43
+
44
+ [tool.pyright]
45
+ include = ["src", "tests"]
46
+ exclude = ["**/node_modules", "**/__pycache__", ".venv", ".git", "dist"]
47
+ pythonVersion = "3.10"
48
+ pythonPlatform = "Darwin"
49
+ typeCheckingMode = "basic"
50
+ reportMissingImports = true
51
+ reportMissingTypeStubs = false
52
+ useLibraryCodeForTypes = true
53
+ venvPath = "."
54
+ venv = ".venv"
src/fastmcp/cli/cli.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  import importlib.metadata
4
  import importlib.util
 
5
  import subprocess
6
  import sys
7
  from pathlib import Path
@@ -242,6 +243,7 @@ def dev(
242
  [npx_cmd, "@modelcontextprotocol/inspector"] + uv_cmd,
243
  check=True,
244
  shell=shell,
 
245
  )
246
  sys.exit(process.returncode)
247
  except subprocess.CalledProcessError as e:
@@ -423,7 +425,10 @@ def install(
423
  # Load from .env file if specified
424
  if env_file:
425
  try:
426
- env_dict.update(dotenv.dotenv_values(env_file))
 
 
 
427
  except Exception as e:
428
  logger.error(f"Failed to load .env file: {e}")
429
  sys.exit(1)
 
2
 
3
  import importlib.metadata
4
  import importlib.util
5
+ import os
6
  import subprocess
7
  import sys
8
  from pathlib import Path
 
243
  [npx_cmd, "@modelcontextprotocol/inspector"] + uv_cmd,
244
  check=True,
245
  shell=shell,
246
+ env=dict(os.environ.items()), # Convert to list of tuples for env update
247
  )
248
  sys.exit(process.returncode)
249
  except subprocess.CalledProcessError as e:
 
425
  # Load from .env file if specified
426
  if env_file:
427
  try:
428
+ env_values = dotenv.dotenv_values(env_file)
429
+ env_dict.update(
430
+ (k, str(v)) for k, v in env_values.items() if v is not None
431
+ )
432
  except Exception as e:
433
  logger.error(f"Failed to load .env file: {e}")
434
  sys.exit(1)
src/fastmcp/prompts/base.py CHANGED
@@ -1,43 +1,52 @@
1
  """Base classes for FastMCP prompts."""
2
 
3
  import json
4
- from typing import Any, Callable, Dict, Literal, Optional, Sequence, Union
5
  import inspect
6
 
7
- from pydantic import BaseModel, Field, TypeAdapter, field_validator, validate_call
8
  from mcp.types import TextContent, ImageContent, EmbeddedResource
9
  import pydantic_core
10
 
 
 
11
 
12
  class Message(BaseModel):
13
  """Base class for all prompt messages."""
14
 
15
  role: Literal["user", "assistant"]
16
- content: Union[TextContent, ImageContent, EmbeddedResource]
17
 
18
- def __init__(self, content, **kwargs):
 
 
19
  super().__init__(content=content, **kwargs)
20
 
21
- @field_validator("content", mode="before")
22
- def validate_content(cls, v):
23
- if isinstance(v, str):
24
- return TextContent(type="text", text=v)
25
- return v
26
-
27
 
28
  class UserMessage(Message):
29
  """A message from the user."""
30
 
31
  role: Literal["user"] = "user"
32
 
 
 
 
33
 
34
  class AssistantMessage(Message):
35
  """A message from the assistant."""
36
 
37
  role: Literal["assistant"] = "assistant"
38
 
 
 
 
 
 
39
 
40
- message_validator = TypeAdapter(Union[UserMessage, AssistantMessage])
 
 
 
41
 
42
 
43
  class PromptArgument(BaseModel):
@@ -67,11 +76,18 @@ class Prompt(BaseModel):
67
  @classmethod
68
  def from_function(
69
  cls,
70
- fn: Callable[..., Sequence[Message]],
71
  name: Optional[str] = None,
72
  description: Optional[str] = None,
73
  ) -> "Prompt":
74
- """Create a Prompt from a function."""
 
 
 
 
 
 
 
75
  func_name = name or fn.__name__
76
 
77
  if func_name == "<lambda>":
 
1
  """Base classes for FastMCP prompts."""
2
 
3
  import json
4
+ from typing import Any, Callable, Dict, Literal, Optional, Sequence, Awaitable
5
  import inspect
6
 
7
+ from pydantic import BaseModel, Field, TypeAdapter, validate_call
8
  from mcp.types import TextContent, ImageContent, EmbeddedResource
9
  import pydantic_core
10
 
11
+ CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource
12
+
13
 
14
  class Message(BaseModel):
15
  """Base class for all prompt messages."""
16
 
17
  role: Literal["user", "assistant"]
18
+ content: CONTENT_TYPES
19
 
20
+ def __init__(self, content: str | CONTENT_TYPES, **kwargs):
21
+ if isinstance(content, str):
22
+ content = TextContent(type="text", text=content)
23
  super().__init__(content=content, **kwargs)
24
 
 
 
 
 
 
 
25
 
26
  class UserMessage(Message):
27
  """A message from the user."""
28
 
29
  role: Literal["user"] = "user"
30
 
31
+ def __init__(self, content: str | CONTENT_TYPES, **kwargs):
32
+ super().__init__(content=content, **kwargs)
33
+
34
 
35
  class AssistantMessage(Message):
36
  """A message from the assistant."""
37
 
38
  role: Literal["assistant"] = "assistant"
39
 
40
+ def __init__(self, content: str | CONTENT_TYPES, **kwargs):
41
+ super().__init__(content=content, **kwargs)
42
+
43
+
44
+ message_validator = TypeAdapter(UserMessage | AssistantMessage)
45
 
46
+ SyncPromptResult = (
47
+ str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]]
48
+ )
49
+ PromptResult = SyncPromptResult | Awaitable[SyncPromptResult]
50
 
51
 
52
  class PromptArgument(BaseModel):
 
76
  @classmethod
77
  def from_function(
78
  cls,
79
+ fn: Callable[..., PromptResult],
80
  name: Optional[str] = None,
81
  description: Optional[str] = None,
82
  ) -> "Prompt":
83
+ """Create a Prompt from a function.
84
+
85
+ The function can return:
86
+ - A string (converted to a message)
87
+ - A Message object
88
+ - A dict (converted to a message)
89
+ - A sequence of any of the above
90
+ """
91
  func_name = name or fn.__name__
92
 
93
  if func_name == "<lambda>":
src/fastmcp/resources/base.py CHANGED
@@ -1,14 +1,14 @@
1
  """Base classes and interfaces for FastMCP resources."""
2
 
3
  import abc
4
- from typing import Union
5
 
6
  from pydantic import (
7
  AnyUrl,
8
  BaseModel,
9
  ConfigDict,
10
  Field,
11
- FileUrl,
12
  ValidationInfo,
13
  field_validator,
14
  )
@@ -19,8 +19,9 @@ class Resource(BaseModel, abc.ABC):
19
 
20
  model_config = ConfigDict(validate_default=True)
21
 
22
- # uri: Annotated[AnyUrl, BeforeValidator(maybe_cast_str_to_any_url)] = Field(
23
- uri: AnyUrl = Field(default=..., description="URI of the resource")
 
24
  name: str | None = Field(description="Name of the resource", default=None)
25
  description: str | None = Field(
26
  description="Description of the resource", default=None
@@ -31,15 +32,6 @@ class Resource(BaseModel, abc.ABC):
31
  pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
32
  )
33
 
34
- @field_validator("uri", mode="before")
35
- def validate_uri(cls, uri: AnyUrl | str) -> AnyUrl:
36
- if isinstance(uri, str):
37
- # AnyUrl doesn't support triple-slashes, but files do ("file:///absolute/path")
38
- if uri.startswith("file://"):
39
- return FileUrl(uri)
40
- return AnyUrl(uri)
41
- return uri
42
-
43
  @field_validator("name", mode="before")
44
  @classmethod
45
  def set_default_name(cls, name: str | None, info: ValidationInfo) -> str:
 
1
  """Base classes and interfaces for FastMCP resources."""
2
 
3
  import abc
4
+ from typing import Union, Annotated
5
 
6
  from pydantic import (
7
  AnyUrl,
8
  BaseModel,
9
  ConfigDict,
10
  Field,
11
+ UrlConstraints,
12
  ValidationInfo,
13
  field_validator,
14
  )
 
19
 
20
  model_config = ConfigDict(validate_default=True)
21
 
22
+ uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field(
23
+ default=..., description="URI of the resource"
24
+ )
25
  name: str | None = Field(description="Name of the resource", default=None)
26
  description: str | None = Field(
27
  description="Description of the resource", default=None
 
32
  pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
33
  )
34
 
 
 
 
 
 
 
 
 
 
35
  @field_validator("name", mode="before")
36
  @classmethod
37
  def set_default_name(cls, name: str | None, info: ValidationInfo) -> str:
src/fastmcp/resources/templates.py CHANGED
@@ -70,7 +70,7 @@ class ResourceTemplate(BaseModel):
70
  result = await result
71
 
72
  return FunctionResource(
73
- uri=uri,
74
  name=self.name,
75
  description=self.description,
76
  mime_type=self.mime_type,
 
70
  result = await result
71
 
72
  return FunctionResource(
73
+ uri=uri, # type: ignore
74
  name=self.name,
75
  description=self.description,
76
  mime_type=self.mime_type,
src/fastmcp/resources/types.py CHANGED
@@ -8,7 +8,7 @@ from typing import Any, Callable, Union
8
  import httpx
9
  import pydantic.json
10
  import pydantic_core
11
- from pydantic import Field
12
 
13
  from fastmcp.resources.base import Resource
14
 
@@ -91,6 +91,15 @@ class FileResource(Resource):
91
  raise ValueError("Path must be absolute")
92
  return path
93
 
 
 
 
 
 
 
 
 
 
94
  async def read(self) -> Union[str, bytes]:
95
  """Read the file content."""
96
  try:
 
8
  import httpx
9
  import pydantic.json
10
  import pydantic_core
11
+ from pydantic import Field, ValidationInfo
12
 
13
  from fastmcp.resources.base import Resource
14
 
 
91
  raise ValueError("Path must be absolute")
92
  return path
93
 
94
+ @pydantic.field_validator("is_binary")
95
+ @classmethod
96
+ def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool:
97
+ """Set is_binary based on mime_type if not explicitly set."""
98
+ if is_binary:
99
+ return True
100
+ mime_type = info.data.get("mime_type", "text/plain")
101
+ return not mime_type.startswith("text/")
102
+
103
  async def read(self) -> Union[str, bytes]:
104
  """Read the file content."""
105
  try:
src/fastmcp/server.py CHANGED
@@ -23,6 +23,7 @@ from mcp.types import (
23
  )
24
  from mcp.types import (
25
  Prompt as MCPPrompt,
 
26
  )
27
  from mcp.types import (
28
  Resource as MCPResource,
@@ -159,7 +160,7 @@ class FastMCP:
159
 
160
  async def call_tool(
161
  self, name: str, arguments: dict
162
- ) -> Sequence[TextContent | ImageContent]:
163
  """Call a tool by name with arguments."""
164
  context = self.get_context()
165
  result = await self._tool_manager.call_tool(name, arguments, context=context)
@@ -462,11 +463,11 @@ class FastMCP:
462
  name=prompt.name,
463
  description=prompt.description,
464
  arguments=[
465
- {
466
- "name": arg.name,
467
- "description": arg.description,
468
- "required": arg.required,
469
- }
470
  for arg in (prompt.arguments or [])
471
  ],
472
  )
 
23
  )
24
  from mcp.types import (
25
  Prompt as MCPPrompt,
26
+ PromptArgument as MCPPromptArgument,
27
  )
28
  from mcp.types import (
29
  Resource as MCPResource,
 
160
 
161
  async def call_tool(
162
  self, name: str, arguments: dict
163
+ ) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
164
  """Call a tool by name with arguments."""
165
  context = self.get_context()
166
  result = await self._tool_manager.call_tool(name, arguments, context=context)
 
463
  name=prompt.name,
464
  description=prompt.description,
465
  arguments=[
466
+ MCPPromptArgument(
467
+ name=arg.name,
468
+ description=arg.description,
469
+ required=arg.required,
470
+ )
471
  for arg in (prompt.arguments or [])
472
  ],
473
  )
src/fastmcp/utilities/func_metadata.py CHANGED
@@ -47,7 +47,7 @@ class FuncMetadata(BaseModel):
47
 
48
  async def call_fn_with_arg_validation(
49
  self,
50
- fn: Callable | Awaitable,
51
  fn_is_async: bool,
52
  arguments_to_validate: dict[str, Any],
53
  arguments_to_pass_directly: dict[str, Any] | None,
@@ -64,8 +64,12 @@ class FuncMetadata(BaseModel):
64
  arguments_parsed_dict |= arguments_to_pass_directly or {}
65
 
66
  if fn_is_async:
 
 
67
  return await fn(**arguments_parsed_dict)
68
- return fn(**arguments_parsed_dict)
 
 
69
 
70
  def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
71
  """Pre-parse data from JSON.
@@ -123,6 +127,7 @@ def func_metadata(func: Callable, skip_names: Sequence[str] = ()) -> FuncMetadat
123
  sig = _get_typed_signature(func)
124
  params = sig.parameters
125
  dynamic_pydantic_model_params: dict[str, Any] = {}
 
126
  for param in params.values():
127
  if param.name.startswith("_"):
128
  raise InvalidSignature(
@@ -153,7 +158,7 @@ def func_metadata(func: Callable, skip_names: Sequence[str] = ()) -> FuncMetadat
153
  ]
154
 
155
  field_info = FieldInfo.from_annotated_attribute(
156
- annotation,
157
  param.default
158
  if param.default is not inspect.Parameter.empty
159
  else PydanticUndefined,
 
47
 
48
  async def call_fn_with_arg_validation(
49
  self,
50
+ fn: Callable[..., Any] | Awaitable[Any],
51
  fn_is_async: bool,
52
  arguments_to_validate: dict[str, Any],
53
  arguments_to_pass_directly: dict[str, Any] | None,
 
64
  arguments_parsed_dict |= arguments_to_pass_directly or {}
65
 
66
  if fn_is_async:
67
+ if isinstance(fn, Awaitable):
68
+ return await fn
69
  return await fn(**arguments_parsed_dict)
70
+ if isinstance(fn, Callable):
71
+ return fn(**arguments_parsed_dict)
72
+ raise TypeError("fn must be either Callable or Awaitable")
73
 
74
  def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
75
  """Pre-parse data from JSON.
 
127
  sig = _get_typed_signature(func)
128
  params = sig.parameters
129
  dynamic_pydantic_model_params: dict[str, Any] = {}
130
+ globalns = getattr(func, "__globals__", {})
131
  for param in params.values():
132
  if param.name.startswith("_"):
133
  raise InvalidSignature(
 
158
  ]
159
 
160
  field_info = FieldInfo.from_annotated_attribute(
161
+ _get_typed_annotation(annotation, globalns),
162
  param.default
163
  if param.default is not inspect.Parameter.empty
164
  else PydanticUndefined,
src/fastmcp/utilities/types.py CHANGED
@@ -47,7 +47,9 @@ class Image:
47
  if self.path:
48
  with open(self.path, "rb") as f:
49
  data = base64.b64encode(f.read()).decode()
50
- else:
51
  data = base64.b64encode(self.data).decode()
 
 
52
 
53
  return ImageContent(type="image", data=data, mimeType=self._mime_type)
 
47
  if self.path:
48
  with open(self.path, "rb") as f:
49
  data = base64.b64encode(f.read()).decode()
50
+ elif self.data is not None:
51
  data = base64.b64encode(self.data).decode()
52
+ else:
53
+ raise ValueError("No image data available")
54
 
55
  return ImageContent(type="image", data=data, mimeType=self._mime_type)
tests/prompts/test_base.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import pytest
2
  from fastmcp.prompts.base import (
3
  Prompt,
@@ -102,7 +103,7 @@ class TestRenderPrompt:
102
  content=EmbeddedResource(
103
  type="resource",
104
  resource=TextResourceContents(
105
- uri="file://file.txt",
106
  text="File contents",
107
  mimeType="text/plain",
108
  ),
@@ -115,7 +116,7 @@ class TestRenderPrompt:
115
  content=EmbeddedResource(
116
  type="resource",
117
  resource=TextResourceContents(
118
- uri="file://file.txt",
119
  text="File contents",
120
  mimeType="text/plain",
121
  ),
@@ -133,7 +134,7 @@ class TestRenderPrompt:
133
  content=EmbeddedResource(
134
  type="resource",
135
  resource=TextResourceContents(
136
- uri="file://file.txt",
137
  text="File contents",
138
  mimeType="text/plain",
139
  ),
@@ -151,7 +152,7 @@ class TestRenderPrompt:
151
  content=EmbeddedResource(
152
  type="resource",
153
  resource=TextResourceContents(
154
- uri="file://file.txt",
155
  text="File contents",
156
  mimeType="text/plain",
157
  ),
@@ -171,7 +172,7 @@ class TestRenderPrompt:
171
  "content": {
172
  "type": "resource",
173
  "resource": {
174
- "uri": "file://file.txt",
175
  "text": "File contents",
176
  "mimeType": "text/plain",
177
  },
@@ -184,7 +185,7 @@ class TestRenderPrompt:
184
  content=EmbeddedResource(
185
  type="resource",
186
  resource=TextResourceContents(
187
- uri="file://file.txt",
188
  text="File contents",
189
  mimeType="text/plain",
190
  ),
 
1
+ from pydantic import FileUrl
2
  import pytest
3
  from fastmcp.prompts.base import (
4
  Prompt,
 
103
  content=EmbeddedResource(
104
  type="resource",
105
  resource=TextResourceContents(
106
+ uri=FileUrl("file://file.txt"),
107
  text="File contents",
108
  mimeType="text/plain",
109
  ),
 
116
  content=EmbeddedResource(
117
  type="resource",
118
  resource=TextResourceContents(
119
+ uri=FileUrl("file://file.txt"),
120
  text="File contents",
121
  mimeType="text/plain",
122
  ),
 
134
  content=EmbeddedResource(
135
  type="resource",
136
  resource=TextResourceContents(
137
+ uri=FileUrl("file://file.txt"),
138
  text="File contents",
139
  mimeType="text/plain",
140
  ),
 
152
  content=EmbeddedResource(
153
  type="resource",
154
  resource=TextResourceContents(
155
+ uri=FileUrl("file://file.txt"),
156
  text="File contents",
157
  mimeType="text/plain",
158
  ),
 
172
  "content": {
173
  "type": "resource",
174
  "resource": {
175
+ "uri": FileUrl("file://file.txt"),
176
  "text": "File contents",
177
  "mimeType": "text/plain",
178
  },
 
185
  content=EmbeddedResource(
186
  type="resource",
187
  resource=TextResourceContents(
188
+ uri=FileUrl("file://file.txt"),
189
  text="File contents",
190
  mimeType="text/plain",
191
  ),
tests/resources/test_file_resources.py CHANGED
@@ -3,6 +3,7 @@ import os
3
  import pytest
4
  from pathlib import Path
5
  from tempfile import NamedTemporaryFile
 
6
 
7
  from fastmcp.resources import FileResource
8
 
@@ -30,7 +31,7 @@ class TestFileResource:
30
  def test_file_resource_creation(self, temp_file: Path):
31
  """Test creating a FileResource."""
32
  resource = FileResource(
33
- uri=temp_file.as_uri(),
34
  name="test",
35
  description="test file",
36
  path=temp_file,
@@ -45,9 +46,9 @@ class TestFileResource:
45
  def test_file_resource_str_path_conversion(self, temp_file: Path):
46
  """Test FileResource handles string paths."""
47
  resource = FileResource(
48
- uri=f"file://{temp_file}",
49
  name="test",
50
- path=str(temp_file),
51
  )
52
  assert isinstance(resource.path, Path)
53
  assert resource.path.is_absolute()
@@ -55,7 +56,7 @@ class TestFileResource:
55
  async def test_read_text_file(self, temp_file: Path):
56
  """Test reading a text file."""
57
  resource = FileResource(
58
- uri=f"file://{temp_file}",
59
  name="test",
60
  path=temp_file,
61
  )
@@ -66,7 +67,7 @@ class TestFileResource:
66
  async def test_read_binary_file(self, temp_file: Path):
67
  """Test reading a file as binary."""
68
  resource = FileResource(
69
- uri=f"file://{temp_file}",
70
  name="test",
71
  path=temp_file,
72
  is_binary=True,
@@ -79,7 +80,7 @@ class TestFileResource:
79
  """Test error on relative path."""
80
  with pytest.raises(ValueError, match="Path must be absolute"):
81
  FileResource(
82
- uri="file:///test.txt",
83
  name="test",
84
  path=Path("test.txt"),
85
  )
@@ -89,7 +90,7 @@ class TestFileResource:
89
  # Create path to non-existent file
90
  missing = temp_file.parent / "missing.txt"
91
  resource = FileResource(
92
- uri="file:///missing.txt",
93
  name="test",
94
  path=missing,
95
  )
@@ -104,7 +105,7 @@ class TestFileResource:
104
  temp_file.chmod(0o000) # Remove all permissions
105
  try:
106
  resource = FileResource(
107
- uri=temp_file.as_uri(),
108
  name="test",
109
  path=temp_file,
110
  )
 
3
  import pytest
4
  from pathlib import Path
5
  from tempfile import NamedTemporaryFile
6
+ from pydantic import FileUrl
7
 
8
  from fastmcp.resources import FileResource
9
 
 
31
  def test_file_resource_creation(self, temp_file: Path):
32
  """Test creating a FileResource."""
33
  resource = FileResource(
34
+ uri=FileUrl(temp_file.as_uri()),
35
  name="test",
36
  description="test file",
37
  path=temp_file,
 
46
  def test_file_resource_str_path_conversion(self, temp_file: Path):
47
  """Test FileResource handles string paths."""
48
  resource = FileResource(
49
+ uri=FileUrl(f"file://{temp_file}"),
50
  name="test",
51
+ path=Path(str(temp_file)),
52
  )
53
  assert isinstance(resource.path, Path)
54
  assert resource.path.is_absolute()
 
56
  async def test_read_text_file(self, temp_file: Path):
57
  """Test reading a text file."""
58
  resource = FileResource(
59
+ uri=FileUrl(f"file://{temp_file}"),
60
  name="test",
61
  path=temp_file,
62
  )
 
67
  async def test_read_binary_file(self, temp_file: Path):
68
  """Test reading a file as binary."""
69
  resource = FileResource(
70
+ uri=FileUrl(f"file://{temp_file}"),
71
  name="test",
72
  path=temp_file,
73
  is_binary=True,
 
80
  """Test error on relative path."""
81
  with pytest.raises(ValueError, match="Path must be absolute"):
82
  FileResource(
83
+ uri=FileUrl("file:///test.txt"),
84
  name="test",
85
  path=Path("test.txt"),
86
  )
 
90
  # Create path to non-existent file
91
  missing = temp_file.parent / "missing.txt"
92
  resource = FileResource(
93
+ uri=FileUrl("file:///missing.txt"),
94
  name="test",
95
  path=missing,
96
  )
 
105
  temp_file.chmod(0o000) # Remove all permissions
106
  try:
107
  resource = FileResource(
108
+ uri=FileUrl(temp_file.as_uri()),
109
  name="test",
110
  path=temp_file,
111
  )
tests/resources/test_function_resources.py CHANGED
@@ -1,4 +1,4 @@
1
- from pydantic import BaseModel
2
  import pytest
3
  from fastmcp.resources import FunctionResource
4
 
@@ -13,7 +13,7 @@ class TestFunctionResource:
13
  return "test content"
14
 
15
  resource = FunctionResource(
16
- uri="fn://test",
17
  name="test",
18
  description="test function",
19
  fn=my_func,
@@ -31,7 +31,7 @@ class TestFunctionResource:
31
  return "Hello, world!"
32
 
33
  resource = FunctionResource(
34
- uri="function://test",
35
  name="test",
36
  fn=get_data,
37
  )
@@ -46,7 +46,7 @@ class TestFunctionResource:
46
  return b"Hello, world!"
47
 
48
  resource = FunctionResource(
49
- uri="function://test",
50
  name="test",
51
  fn=get_data,
52
  )
@@ -60,11 +60,12 @@ class TestFunctionResource:
60
  return {"key": "value"}
61
 
62
  resource = FunctionResource(
63
- uri="function://test",
64
  name="test",
65
  fn=get_data,
66
  )
67
  content = await resource.read()
 
68
  assert '"key": "value"' in content
69
 
70
  async def test_error_handling(self):
@@ -74,7 +75,7 @@ class TestFunctionResource:
74
  raise ValueError("Test error")
75
 
76
  resource = FunctionResource(
77
- uri="function://test",
78
  name="test",
79
  fn=failing_func,
80
  )
@@ -88,7 +89,7 @@ class TestFunctionResource:
88
  name: str
89
 
90
  resource = FunctionResource(
91
- uri="function://test",
92
  name="test",
93
  fn=lambda: MyModel(name="test"),
94
  )
@@ -106,7 +107,7 @@ class TestFunctionResource:
106
  return CustomData()
107
 
108
  resource = FunctionResource(
109
- uri="function://test",
110
  name="test",
111
  fn=get_data,
112
  )
 
1
+ from pydantic import BaseModel, AnyUrl
2
  import pytest
3
  from fastmcp.resources import FunctionResource
4
 
 
13
  return "test content"
14
 
15
  resource = FunctionResource(
16
+ uri=AnyUrl("fn://test"),
17
  name="test",
18
  description="test function",
19
  fn=my_func,
 
31
  return "Hello, world!"
32
 
33
  resource = FunctionResource(
34
+ uri=AnyUrl("function://test"),
35
  name="test",
36
  fn=get_data,
37
  )
 
46
  return b"Hello, world!"
47
 
48
  resource = FunctionResource(
49
+ uri=AnyUrl("function://test"),
50
  name="test",
51
  fn=get_data,
52
  )
 
60
  return {"key": "value"}
61
 
62
  resource = FunctionResource(
63
+ uri=AnyUrl("function://test"),
64
  name="test",
65
  fn=get_data,
66
  )
67
  content = await resource.read()
68
+ assert isinstance(content, str)
69
  assert '"key": "value"' in content
70
 
71
  async def test_error_handling(self):
 
75
  raise ValueError("Test error")
76
 
77
  resource = FunctionResource(
78
+ uri=AnyUrl("function://test"),
79
  name="test",
80
  fn=failing_func,
81
  )
 
89
  name: str
90
 
91
  resource = FunctionResource(
92
+ uri=AnyUrl("function://test"),
93
  name="test",
94
  fn=lambda: MyModel(name="test"),
95
  )
 
107
  return CustomData()
108
 
109
  resource = FunctionResource(
110
+ uri=AnyUrl("function://test"),
111
  name="test",
112
  fn=get_data,
113
  )
tests/resources/test_resource_manager.py CHANGED
@@ -1,6 +1,7 @@
1
  import pytest
2
  from pathlib import Path
3
  from tempfile import NamedTemporaryFile
 
4
 
5
  from fastmcp.resources import (
6
  FileResource,
@@ -34,7 +35,7 @@ class TestResourceManager:
34
  """Test adding a resource."""
35
  manager = ResourceManager()
36
  resource = FileResource(
37
- uri=f"file://{temp_file}",
38
  name="test",
39
  path=temp_file,
40
  )
@@ -46,7 +47,7 @@ class TestResourceManager:
46
  """Test adding the same resource twice."""
47
  manager = ResourceManager()
48
  resource = FileResource(
49
- uri=f"file://{temp_file}",
50
  name="test",
51
  path=temp_file,
52
  )
@@ -59,7 +60,7 @@ class TestResourceManager:
59
  """Test warning on duplicate resources."""
60
  manager = ResourceManager()
61
  resource = FileResource(
62
- uri=f"file://{temp_file}",
63
  name="test",
64
  path=temp_file,
65
  )
@@ -71,7 +72,7 @@ class TestResourceManager:
71
  """Test disabling warning on duplicate resources."""
72
  manager = ResourceManager(warn_on_duplicate_resources=False)
73
  resource = FileResource(
74
- uri=f"file://{temp_file}",
75
  name="test",
76
  path=temp_file,
77
  )
@@ -83,7 +84,7 @@ class TestResourceManager:
83
  """Test getting a resource by URI."""
84
  manager = ResourceManager()
85
  resource = FileResource(
86
- uri=f"file://{temp_file}",
87
  name="test",
88
  path=temp_file,
89
  )
@@ -105,7 +106,7 @@ class TestResourceManager:
105
  )
106
  manager._templates[template.uri_template] = template
107
 
108
- resource = await manager.get_resource("greet://world")
109
  assert isinstance(resource, FunctionResource)
110
  content = await resource.read()
111
  assert content == "Hello, world!"
@@ -114,18 +115,18 @@ class TestResourceManager:
114
  """Test getting a non-existent resource."""
115
  manager = ResourceManager()
116
  with pytest.raises(ValueError, match="Unknown resource"):
117
- await manager.get_resource("unknown://test")
118
 
119
  def test_list_resources(self, temp_file: Path):
120
  """Test listing all resources."""
121
  manager = ResourceManager()
122
  resource1 = FileResource(
123
- uri=f"file://{temp_file}",
124
  name="test1",
125
  path=temp_file,
126
  )
127
  resource2 = FileResource(
128
- uri=f"file://{temp_file}2",
129
  name="test2",
130
  path=temp_file,
131
  )
 
1
  import pytest
2
  from pathlib import Path
3
  from tempfile import NamedTemporaryFile
4
+ from pydantic import AnyUrl, FileUrl
5
 
6
  from fastmcp.resources import (
7
  FileResource,
 
35
  """Test adding a resource."""
36
  manager = ResourceManager()
37
  resource = FileResource(
38
+ uri=FileUrl(f"file://{temp_file}"),
39
  name="test",
40
  path=temp_file,
41
  )
 
47
  """Test adding the same resource twice."""
48
  manager = ResourceManager()
49
  resource = FileResource(
50
+ uri=FileUrl(f"file://{temp_file}"),
51
  name="test",
52
  path=temp_file,
53
  )
 
60
  """Test warning on duplicate resources."""
61
  manager = ResourceManager()
62
  resource = FileResource(
63
+ uri=FileUrl(f"file://{temp_file}"),
64
  name="test",
65
  path=temp_file,
66
  )
 
72
  """Test disabling warning on duplicate resources."""
73
  manager = ResourceManager(warn_on_duplicate_resources=False)
74
  resource = FileResource(
75
+ uri=FileUrl(f"file://{temp_file}"),
76
  name="test",
77
  path=temp_file,
78
  )
 
84
  """Test getting a resource by URI."""
85
  manager = ResourceManager()
86
  resource = FileResource(
87
+ uri=FileUrl(f"file://{temp_file}"),
88
  name="test",
89
  path=temp_file,
90
  )
 
106
  )
107
  manager._templates[template.uri_template] = template
108
 
109
+ resource = await manager.get_resource(AnyUrl("greet://world"))
110
  assert isinstance(resource, FunctionResource)
111
  content = await resource.read()
112
  assert content == "Hello, world!"
 
115
  """Test getting a non-existent resource."""
116
  manager = ResourceManager()
117
  with pytest.raises(ValueError, match="Unknown resource"):
118
+ await manager.get_resource(AnyUrl("unknown://test"))
119
 
120
  def test_list_resources(self, temp_file: Path):
121
  """Test listing all resources."""
122
  manager = ResourceManager()
123
  resource1 = FileResource(
124
+ uri=FileUrl(f"file://{temp_file}"),
125
  name="test1",
126
  path=temp_file,
127
  )
128
  resource2 = FileResource(
129
+ uri=FileUrl(f"file://{temp_file}2"),
130
  name="test2",
131
  path=temp_file,
132
  )
tests/resources/test_resource_template.py CHANGED
@@ -1,121 +1,72 @@
 
1
  import pytest
2
- from fastmcp.resources import ResourceTemplate, FunctionResource
 
 
3
 
4
 
5
  class TestResourceTemplate:
6
  """Test ResourceTemplate functionality."""
7
 
8
- def test_template_from_function(self):
9
  """Test creating a template from a function."""
10
 
11
- def weather(city: str, units: str = "metric") -> str:
12
- return f"Weather in {city} ({units})"
13
 
14
  template = ResourceTemplate.from_function(
15
- fn=weather,
16
- uri_template="weather://{city}/current",
17
- name="weather",
18
- description="Get current weather",
19
  )
20
-
21
- assert template.name == "weather"
22
- assert template.uri_template == "weather://{city}/current"
23
- assert template.mime_type == "text/plain"
24
- assert "city" in template.parameters["properties"]
25
-
26
- def test_template_from_lambda_error(self):
27
- """Test error when creating template from lambda without name."""
28
- with pytest.raises(
29
- ValueError, match="You must provide a name for lambda functions"
30
- ):
31
- ResourceTemplate.from_function(
32
- fn=lambda x: x,
33
- uri_template="test://{x}",
34
- )
35
 
36
  def test_template_matches(self):
37
- """Test URI matching against template."""
38
 
39
- def dummy(x: str) -> str:
40
- return x
41
 
42
  template = ResourceTemplate.from_function(
43
- fn=dummy,
44
- uri_template="test://{x}/value",
45
  name="test",
46
  )
47
 
48
- # Test matching URI
49
- params = template.matches("test://hello/value")
50
- assert params == {"x": "hello"}
51
 
52
- # Test non-matching URI
53
- params = template.matches("test://hello/wrong")
54
- assert params is None
55
 
56
- async def test_create_text_resource(self):
57
- """Test creating a text resource from template."""
58
 
59
- def greet(name: str) -> str:
60
- return f"Hello, {name}!"
61
 
62
  template = ResourceTemplate.from_function(
63
- fn=greet,
64
- uri_template="greet://{name}",
65
- name="greeter",
66
- )
67
-
68
- resource = await template.create_resource(
69
- "greet://world",
70
- {"name": "world"},
71
- )
72
-
73
- assert isinstance(resource, FunctionResource)
74
- content = await resource.read()
75
- assert content == "Hello, world!"
76
-
77
- async def test_create_binary_resource(self):
78
- """Test creating a binary resource from template."""
79
-
80
- def get_bytes(value: str) -> bytes:
81
- return value.encode()
82
-
83
- template = ResourceTemplate.from_function(
84
- fn=get_bytes,
85
- uri_template="bytes://{value}",
86
- name="bytes",
87
- )
88
-
89
- resource = await template.create_resource(
90
- "bytes://test",
91
- {"value": "test"},
92
- )
93
-
94
- assert isinstance(resource, FunctionResource)
95
- content = await resource.read()
96
- assert content == b"test"
97
-
98
- async def test_json_conversion(self):
99
- """Test automatic JSON conversion of non-string/bytes results."""
100
-
101
- def get_data(key: str) -> dict:
102
- return {"key": key, "value": 123}
103
-
104
- template = ResourceTemplate.from_function(
105
- fn=get_data,
106
- uri_template="data://{key}",
107
- name="data",
108
  )
109
 
110
  resource = await template.create_resource(
111
- "data://test",
112
- {"key": "test"},
113
  )
114
 
115
  assert isinstance(resource, FunctionResource)
116
  content = await resource.read()
117
- assert '"key": "test"' in content
118
- assert '"value": 123' in content
 
119
 
120
  async def test_template_error(self):
121
  """Test error handling in template resource creation."""
@@ -174,65 +125,57 @@ class TestResourceTemplate:
174
  content = await resource.read()
175
  assert content == b"test"
176
 
177
- async def test_async_json_conversion(self):
178
- """Test automatic JSON conversion of async results."""
179
 
180
- async def get_data(key: str) -> dict:
181
- return {"key": key, "value": 123}
 
 
 
 
182
 
183
  template = ResourceTemplate.from_function(
184
  fn=get_data,
185
- uri_template="data://{key}",
186
- name="data",
187
  )
188
 
189
  resource = await template.create_resource(
190
- "data://test",
191
- {"key": "test"},
192
  )
193
 
194
  assert isinstance(resource, FunctionResource)
195
  content = await resource.read()
196
- assert '"key": "test"' in content
197
- assert '"value": 123' in content
198
-
199
- async def test_async_error(self):
200
- """Test error handling in async template."""
201
-
202
- async def failing_func(x: str) -> str:
203
- raise ValueError("Test error")
204
 
205
- template = ResourceTemplate.from_function(
206
- fn=failing_func,
207
- uri_template="fail://{x}",
208
- name="fail",
209
- )
210
-
211
- with pytest.raises(
212
- ValueError, match="Error creating resource from template: Test error"
213
- ):
214
- await template.create_resource("fail://test", {"x": "test"})
215
 
216
- async def test_sync_returning_coroutine(self):
217
- """Test sync function that returns a coroutine."""
 
218
 
219
- async def async_helper(name: str) -> str:
220
- return f"Hello, {name}!"
221
 
222
- def get_greeting(name: str) -> str:
223
- return async_helper(name) # Returns coroutine
224
 
225
  template = ResourceTemplate.from_function(
226
- fn=get_greeting,
227
- uri_template="greet://{name}",
228
- name="greeter",
229
  )
230
 
231
  resource = await template.create_resource(
232
- "greet://world",
233
- {"name": "world"},
234
  )
235
 
236
  assert isinstance(resource, FunctionResource)
237
  content = await resource.read()
238
- assert content == "Hello, world!"
 
1
+ import json
2
  import pytest
3
+ from pydantic import BaseModel
4
+
5
+ from fastmcp.resources import FunctionResource, ResourceTemplate
6
 
7
 
8
  class TestResourceTemplate:
9
  """Test ResourceTemplate functionality."""
10
 
11
+ def test_template_creation(self):
12
  """Test creating a template from a function."""
13
 
14
+ def my_func(key: str, value: int) -> dict:
15
+ return {"key": key, "value": value}
16
 
17
  template = ResourceTemplate.from_function(
18
+ fn=my_func,
19
+ uri_template="test://{key}/{value}",
20
+ name="test",
 
21
  )
22
+ assert template.uri_template == "test://{key}/{value}"
23
+ assert template.name == "test"
24
+ assert template.mime_type == "text/plain" # default
25
+ test_input = {"key": "test", "value": 42}
26
+ assert template.fn(**test_input) == my_func(**test_input)
 
 
 
 
 
 
 
 
 
 
27
 
28
  def test_template_matches(self):
29
+ """Test matching URIs against a template."""
30
 
31
+ def my_func(key: str, value: int) -> dict:
32
+ return {"key": key, "value": value}
33
 
34
  template = ResourceTemplate.from_function(
35
+ fn=my_func,
36
+ uri_template="test://{key}/{value}",
37
  name="test",
38
  )
39
 
40
+ # Valid match
41
+ params = template.matches("test://foo/123")
42
+ assert params == {"key": "foo", "value": "123"}
43
 
44
+ # No match
45
+ assert template.matches("test://foo") is None
46
+ assert template.matches("other://foo/123") is None
47
 
48
+ async def test_create_resource(self):
49
+ """Test creating a resource from a template."""
50
 
51
+ def my_func(key: str, value: int) -> dict:
52
+ return {"key": key, "value": value}
53
 
54
  template = ResourceTemplate.from_function(
55
+ fn=my_func,
56
+ uri_template="test://{key}/{value}",
57
+ name="test",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  )
59
 
60
  resource = await template.create_resource(
61
+ "test://foo/123",
62
+ {"key": "foo", "value": 123},
63
  )
64
 
65
  assert isinstance(resource, FunctionResource)
66
  content = await resource.read()
67
+ assert isinstance(content, str)
68
+ data = json.loads(content)
69
+ assert data == {"key": "foo", "value": 123}
70
 
71
  async def test_template_error(self):
72
  """Test error handling in template resource creation."""
 
125
  content = await resource.read()
126
  assert content == b"test"
127
 
128
+ async def test_basemodel_conversion(self):
129
+ """Test handling of BaseModel types."""
130
 
131
+ class MyModel(BaseModel):
132
+ key: str
133
+ value: int
134
+
135
+ def get_data(key: str, value: int) -> MyModel:
136
+ return MyModel(key=key, value=value)
137
 
138
  template = ResourceTemplate.from_function(
139
  fn=get_data,
140
+ uri_template="test://{key}/{value}",
141
+ name="test",
142
  )
143
 
144
  resource = await template.create_resource(
145
+ "test://foo/123",
146
+ {"key": "foo", "value": 123},
147
  )
148
 
149
  assert isinstance(resource, FunctionResource)
150
  content = await resource.read()
151
+ assert isinstance(content, str)
152
+ data = json.loads(content)
153
+ assert data == {"key": "foo", "value": 123}
 
 
 
 
 
154
 
155
+ async def test_custom_type_conversion(self):
156
+ """Test handling of custom types."""
 
 
 
 
 
 
 
 
157
 
158
+ class CustomData:
159
+ def __init__(self, value: str):
160
+ self.value = value
161
 
162
+ def __str__(self) -> str:
163
+ return self.value
164
 
165
+ def get_data(value: str) -> CustomData:
166
+ return CustomData(value)
167
 
168
  template = ResourceTemplate.from_function(
169
+ fn=get_data,
170
+ uri_template="test://{value}",
171
+ name="test",
172
  )
173
 
174
  resource = await template.create_resource(
175
+ "test://hello",
176
+ {"value": "hello"},
177
  )
178
 
179
  assert isinstance(resource, FunctionResource)
180
  content = await resource.read()
181
+ assert content == "hello"
tests/resources/test_resources.py CHANGED
@@ -1,4 +1,5 @@
1
  import pytest
 
2
 
3
  from fastmcp.resources import FunctionResource, Resource
4
 
@@ -14,7 +15,7 @@ class TestResourceValidation:
14
 
15
  # Valid URI
16
  resource = FunctionResource(
17
- uri="http://example.com/data",
18
  name="test",
19
  fn=dummy_func,
20
  )
@@ -23,7 +24,7 @@ class TestResourceValidation:
23
  # Missing protocol
24
  with pytest.raises(ValueError, match="Input should be a valid URL"):
25
  FunctionResource(
26
- uri="invalid",
27
  name="test",
28
  fn=dummy_func,
29
  )
@@ -31,7 +32,7 @@ class TestResourceValidation:
31
  # Missing host
32
  with pytest.raises(ValueError, match="Input should be a valid URL"):
33
  FunctionResource(
34
- uri="http://",
35
  name="test",
36
  fn=dummy_func,
37
  )
@@ -43,7 +44,7 @@ class TestResourceValidation:
43
  return "data"
44
 
45
  resource = FunctionResource(
46
- uri="resource://my-resource",
47
  fn=dummy_func,
48
  )
49
  assert resource.name == "resource://my-resource"
@@ -62,7 +63,7 @@ class TestResourceValidation:
62
 
63
  # Explicit name takes precedence over URI
64
  resource = FunctionResource(
65
- uri="resource://uri-name",
66
  name="explicit-name",
67
  fn=dummy_func,
68
  )
@@ -76,14 +77,14 @@ class TestResourceValidation:
76
 
77
  # Default mime type
78
  resource = FunctionResource(
79
- uri="resource://test",
80
  fn=dummy_func,
81
  )
82
  assert resource.mime_type == "text/plain"
83
 
84
  # Custom mime type
85
  resource = FunctionResource(
86
- uri="resource://test",
87
  fn=dummy_func,
88
  mime_type="application/json",
89
  )
@@ -96,4 +97,4 @@ class TestResourceValidation:
96
  pass
97
 
98
  with pytest.raises(TypeError, match="abstract method"):
99
- ConcreteResource(uri="test://test", name="test") # type: ignore
 
1
  import pytest
2
+ from pydantic import AnyUrl
3
 
4
  from fastmcp.resources import FunctionResource, Resource
5
 
 
15
 
16
  # Valid URI
17
  resource = FunctionResource(
18
+ uri=AnyUrl("http://example.com/data"),
19
  name="test",
20
  fn=dummy_func,
21
  )
 
24
  # Missing protocol
25
  with pytest.raises(ValueError, match="Input should be a valid URL"):
26
  FunctionResource(
27
+ uri=AnyUrl("invalid"),
28
  name="test",
29
  fn=dummy_func,
30
  )
 
32
  # Missing host
33
  with pytest.raises(ValueError, match="Input should be a valid URL"):
34
  FunctionResource(
35
+ uri=AnyUrl("http://"),
36
  name="test",
37
  fn=dummy_func,
38
  )
 
44
  return "data"
45
 
46
  resource = FunctionResource(
47
+ uri=AnyUrl("resource://my-resource"),
48
  fn=dummy_func,
49
  )
50
  assert resource.name == "resource://my-resource"
 
63
 
64
  # Explicit name takes precedence over URI
65
  resource = FunctionResource(
66
+ uri=AnyUrl("resource://uri-name"),
67
  name="explicit-name",
68
  fn=dummy_func,
69
  )
 
77
 
78
  # Default mime type
79
  resource = FunctionResource(
80
+ uri=AnyUrl("resource://test"),
81
  fn=dummy_func,
82
  )
83
  assert resource.mime_type == "text/plain"
84
 
85
  # Custom mime type
86
  resource = FunctionResource(
87
+ uri=AnyUrl("resource://test"),
88
  fn=dummy_func,
89
  mime_type="application/json",
90
  )
 
97
  pass
98
 
99
  with pytest.raises(TypeError, match="abstract method"):
100
+ ConcreteResource(uri=AnyUrl("test://test"), name="test") # type: ignore
tests/servers/test_file_server.py CHANGED
@@ -25,7 +25,7 @@ def mcp() -> FastMCP:
25
 
26
 
27
  @pytest.fixture(autouse=True)
28
- def resources(mcp: FastMCP, test_dir: Path) -> None:
29
  @mcp.resource("dir://test_dir")
30
  def list_test_dir() -> list[str]:
31
  """List the files in the test directory"""
@@ -59,7 +59,7 @@ def resources(mcp: FastMCP, test_dir: Path) -> None:
59
 
60
 
61
  @pytest.fixture(autouse=True)
62
- def tools(mcp: FastMCP, test_dir: Path) -> None:
63
  @mcp.tool()
64
  def delete_file(path: str) -> bool:
65
  # ensure path is in test_dir
@@ -68,6 +68,8 @@ def tools(mcp: FastMCP, test_dir: Path) -> None:
68
  Path(path).unlink()
69
  return True
70
 
 
 
71
 
72
  async def test_list_resources(mcp: FastMCP):
73
  resources = await mcp.list_resources()
 
25
 
26
 
27
  @pytest.fixture(autouse=True)
28
+ def resources(mcp: FastMCP, test_dir: Path) -> FastMCP:
29
  @mcp.resource("dir://test_dir")
30
  def list_test_dir() -> list[str]:
31
  """List the files in the test directory"""
 
59
 
60
 
61
  @pytest.fixture(autouse=True)
62
+ def tools(mcp: FastMCP, test_dir: Path) -> FastMCP:
63
  @mcp.tool()
64
  def delete_file(path: str) -> bool:
65
  # ensure path is in test_dir
 
68
  Path(path).unlink()
69
  return True
70
 
71
+ return mcp
72
+
73
 
74
  async def test_list_resources(mcp: FastMCP):
75
  resources = await mcp.list_resources()
tests/test_cli.py CHANGED
@@ -320,7 +320,11 @@ mcp = FastMCP("test", dependencies=["pandas", "numpy"])
320
  x in deps_section for x in ["--with", "numpy", "--with", "pandas"]
321
  )
322
 
323
- assert mock_run.call_args_list[1][1] == {"check": True, "shell": True}
 
 
 
 
324
  else:
325
  # same verification for unix, just with different command prefix
326
  actual_cmd = mock_run.call_args_list[0][0][0]
@@ -342,7 +346,11 @@ mcp = FastMCP("test", dependencies=["pandas", "numpy"])
342
  x in deps_section for x in ["--with", "numpy", "--with", "pandas"]
343
  )
344
 
345
- assert mock_run.call_args_list[0][1] == {"check": True, "shell": False}
 
 
 
 
346
 
347
 
348
  def test_run_with_dependencies(mock_config, server_file):
 
320
  x in deps_section for x in ["--with", "numpy", "--with", "pandas"]
321
  )
322
 
323
+ # Verify subprocess call kwargs, allowing for environment variables
324
+ call_kwargs = mock_run.call_args_list[1][1]
325
+ assert call_kwargs["check"] is True
326
+ assert call_kwargs["shell"] is True
327
+ assert isinstance(call_kwargs["env"], dict)
328
  else:
329
  # same verification for unix, just with different command prefix
330
  actual_cmd = mock_run.call_args_list[0][0][0]
 
346
  x in deps_section for x in ["--with", "numpy", "--with", "pandas"]
347
  )
348
 
349
+ # Verify subprocess call kwargs, allowing for environment variables
350
+ call_kwargs = mock_run.call_args_list[0][1]
351
+ assert call_kwargs["check"] is True
352
+ assert call_kwargs["shell"] is False
353
+ assert isinstance(call_kwargs["env"], dict)
354
 
355
 
356
  def test_run_with_dependencies(mock_config, server_file):
tests/test_func_metadata.py CHANGED
@@ -192,9 +192,9 @@ def test_skip_names():
192
  assert "also_skip" not in meta.arg_model.model_fields
193
 
194
  # Validate that we can call with only non-skipped parameters
195
- model = meta.arg_model.model_validate({"keep_this": 1, "also_keep": 2.5})
196
- assert model.keep_this == 1
197
- assert model.also_keep == 2.5
198
 
199
 
200
  async def test_lambda_function():
 
192
  assert "also_skip" not in meta.arg_model.model_fields
193
 
194
  # Validate that we can call with only non-skipped parameters
195
+ model: BaseModel = meta.arg_model.model_validate({"keep_this": 1, "also_keep": 2.5}) # type: ignore
196
+ assert model.keep_this == 1 # type: ignore
197
+ assert model.also_keep == 2.5 # type: ignore
198
 
199
 
200
  async def test_lambda_function():
tests/test_server.py CHANGED
@@ -7,7 +7,13 @@ from mcp.shared.exceptions import McpError
7
  from mcp.shared.memory import (
8
  create_connected_server_and_client_session as client_session,
9
  )
10
- from mcp.types import ImageContent, TextContent
 
 
 
 
 
 
11
 
12
  from fastmcp import Context, FastMCP
13
  from fastmcp.prompts.base import EmbeddedResource, Message, UserMessage
@@ -100,7 +106,7 @@ class TestServerTools:
100
  mcp.add_tool(tool_fn)
101
  async with client_session(mcp._mcp_server) as client:
102
  result = await client.call_tool("my_tool", {"arg1": "value"})
103
- assert "error" not in result
104
  assert len(result.content) > 0
105
 
106
  async def test_tool_exception_handling(self):
@@ -109,29 +115,43 @@ class TestServerTools:
109
  async with client_session(mcp._mcp_server) as client:
110
  result = await client.call_tool("error_tool_fn", {})
111
  assert len(result.content) == 1
112
- assert result.content[0].type == "text"
113
- assert "Test error" in result.content[0].text
 
 
 
 
 
 
 
 
 
 
 
 
114
  assert result.isError is True
115
 
116
- async def test_tool_exception_content(self):
117
  """Test that exception details are properly formatted in the response"""
118
  mcp = FastMCP()
119
  mcp.add_tool(error_tool_fn)
120
  async with client_session(mcp._mcp_server) as client:
121
  result = await client.call_tool("error_tool_fn", {})
122
- assert result.content[0].type == "text"
123
- assert isinstance(result.content[0].text, str)
124
- assert "Test error" in result.content[0].text
 
125
  assert result.isError is True
126
 
127
- async def test_tool_text_conversion(self):
128
  mcp = FastMCP()
129
  mcp.add_tool(tool_fn)
130
  async with client_session(mcp._mcp_server) as client:
131
  result = await client.call_tool("tool_fn", {"x": 1, "y": 2})
132
  assert len(result.content) == 1
133
- assert result.content[0].type == "text"
134
- assert result.content[0].text == "3"
 
135
 
136
  async def test_tool_image_helper(self, tmp_path: Path):
137
  # Create a test image
@@ -143,10 +163,12 @@ class TestServerTools:
143
  async with client_session(mcp._mcp_server) as client:
144
  result = await client.call_tool("image_tool_fn", {"path": str(image_path)})
145
  assert len(result.content) == 1
146
- assert result.content[0].type == "image"
147
- assert result.content[0].mimeType == "image/png"
 
 
148
  # Verify base64 encoding
149
- decoded = base64.b64decode(result.content[0].data)
150
  assert decoded == b"fake png data"
151
 
152
  async def test_tool_mixed_content(self):
@@ -155,11 +177,13 @@ class TestServerTools:
155
  async with client_session(mcp._mcp_server) as client:
156
  result = await client.call_tool("mixed_content_tool_fn", {})
157
  assert len(result.content) == 2
158
- assert result.content[0].type == "text"
159
- assert result.content[0].text == "Hello"
160
- assert result.content[1].type == "image"
161
- assert result.content[1].mimeType == "image/png"
162
- assert result.content[1].data == "abc"
 
 
163
 
164
  async def test_tool_mixed_list_with_image(self, tmp_path: Path):
165
  """Test that lists containing Image objects and other types are handled correctly"""
@@ -181,18 +205,22 @@ class TestServerTools:
181
  result = await client.call_tool("mixed_list_fn", {})
182
  assert len(result.content) == 4
183
  # Check text conversion
184
- assert result.content[0].type == "text"
185
- assert "text message" in result.content[0].text
 
186
  # Check image conversion
187
- assert result.content[1].type == "image"
188
- assert result.content[1].mimeType == "image/png"
189
- assert base64.b64decode(result.content[1].data) == b"test image data"
 
190
  # Check dict conversion
191
- assert result.content[2].type == "text"
192
- assert '"key": "value"' in result.content[2].text
 
193
  # Check direct TextContent
194
- assert result.content[3].type == "text"
195
- assert result.content[3].text == "direct content"
 
196
 
197
 
198
  class TestServerResources:
@@ -202,11 +230,14 @@ class TestServerResources:
202
  def get_text():
203
  return "Hello, world!"
204
 
205
- resource = FunctionResource(uri="resource://test", name="test", fn=get_text)
 
 
206
  mcp.add_resource(resource)
207
 
208
  async with client_session(mcp._mcp_server) as client:
209
- result = await client.read_resource("resource://test")
 
210
  assert result.contents[0].text == "Hello, world!"
211
 
212
  async def test_binary_resource(self):
@@ -216,16 +247,16 @@ class TestServerResources:
216
  return b"Binary data"
217
 
218
  resource = FunctionResource(
219
- uri="resource://binary",
220
  name="binary",
221
  fn=get_binary,
222
- is_binary=True,
223
  mime_type="application/octet-stream",
224
  )
225
  mcp.add_resource(resource)
226
 
227
  async with client_session(mcp._mcp_server) as client:
228
- result = await client.read_resource("resource://binary")
 
229
  assert result.contents[0].blob == base64.b64encode(b"Binary data").decode()
230
 
231
  async def test_file_resource_text(self, tmp_path: Path):
@@ -235,11 +266,14 @@ class TestServerResources:
235
  text_file = tmp_path / "test.txt"
236
  text_file.write_text("Hello from file!")
237
 
238
- resource = FileResource(uri="file://test.txt", name="test.txt", path=text_file)
 
 
239
  mcp.add_resource(resource)
240
 
241
  async with client_session(mcp._mcp_server) as client:
242
- result = await client.read_resource("file://test.txt")
 
243
  assert result.contents[0].text == "Hello from file!"
244
 
245
  async def test_file_resource_binary(self, tmp_path: Path):
@@ -250,16 +284,16 @@ class TestServerResources:
250
  binary_file.write_bytes(b"Binary file data")
251
 
252
  resource = FileResource(
253
- uri="file://test.bin",
254
  name="test.bin",
255
  path=binary_file,
256
- is_binary=True,
257
  mime_type="application/octet-stream",
258
  )
259
  mcp.add_resource(resource)
260
 
261
  async with client_session(mcp._mcp_server) as client:
262
- result = await client.read_resource("file://test.bin")
 
263
  assert (
264
  result.contents[0].blob
265
  == base64.b64encode(b"Binary file data").decode()
@@ -275,7 +309,7 @@ class TestServerResourceTemplates:
275
  with pytest.raises(ValueError, match="Mismatch between URI parameters"):
276
 
277
  @mcp.resource("resource://data")
278
- def get_data(param: str) -> str:
279
  return f"Data: {param}"
280
 
281
  async def test_resource_with_uri_params(self):
@@ -305,7 +339,8 @@ class TestServerResourceTemplates:
305
  return f"Data for {name}"
306
 
307
  async with client_session(mcp._mcp_server) as client:
308
- result = await client.read_resource("resource://test/data")
 
309
  assert result.contents[0].text == "Data for test"
310
 
311
  async def test_resource_mismatched_params(self):
@@ -327,7 +362,10 @@ class TestServerResourceTemplates:
327
  return f"Data for {org}/{repo}"
328
 
329
  async with client_session(mcp._mcp_server) as client:
330
- result = await client.read_resource("resource://cursor/fastmcp/data")
 
 
 
331
  assert result.contents[0].text == "Data for cursor/fastmcp"
332
 
333
  async def test_resource_multiple_mismatched_params(self):
@@ -337,18 +375,19 @@ class TestServerResourceTemplates:
337
  with pytest.raises(ValueError, match="Mismatch between URI parameters"):
338
 
339
  @mcp.resource("resource://{org}/{repo}/data")
340
- def get_data(org: str, repo_2: str) -> str:
341
  return f"Data for {org}"
342
 
343
  """Test that a resource with no parameters works as a regular resource"""
344
  mcp = FastMCP()
345
 
346
  @mcp.resource("resource://static")
347
- def get_data() -> str:
348
  return "Static data"
349
 
350
  async with client_session(mcp._mcp_server) as client:
351
- result = await client.read_resource("resource://static")
 
352
  assert result.contents[0].text == "Static data"
353
 
354
  async def test_template_to_resource_conversion(self):
@@ -395,8 +434,10 @@ class TestContextInjection:
395
  async with client_session(mcp._mcp_server) as client:
396
  result = await client.call_tool("tool_with_context", {"x": 42})
397
  assert len(result.content) == 1
398
- assert "Request" in result.content[0].text
399
- assert "42" in result.content[0].text
 
 
400
 
401
  async def test_async_context(self):
402
  """Test that context works in async functions."""
@@ -410,8 +451,10 @@ class TestContextInjection:
410
  async with client_session(mcp._mcp_server) as client:
411
  result = await client.call_tool("async_tool", {"x": 42})
412
  assert len(result.content) == 1
413
- assert "Async request" in result.content[0].text
414
- assert "42" in result.content[0].text
 
 
415
 
416
  async def test_context_logging(self):
417
  """Test that context logging methods work."""
@@ -428,7 +471,9 @@ class TestContextInjection:
428
  async with client_session(mcp._mcp_server) as client:
429
  result = await client.call_tool("logging_tool", {"msg": "test"})
430
  assert len(result.content) == 1
431
- assert "Logged messages for test" in result.content[0].text
 
 
432
 
433
  async def test_optional_context(self):
434
  """Test that context is optional."""
@@ -441,7 +486,9 @@ class TestContextInjection:
441
  async with client_session(mcp._mcp_server) as client:
442
  result = await client.call_tool("no_context", {"x": 21})
443
  assert len(result.content) == 1
444
- assert result.content[0].text == "42"
 
 
445
 
446
  async def test_context_resource_access(self):
447
  """Test that context can access resources."""
@@ -459,7 +506,9 @@ class TestContextInjection:
459
  async with client_session(mcp._mcp_server) as client:
460
  result = await client.call_tool("tool_with_resource", {})
461
  assert len(result.content) == 1
462
- assert "Read resource: resource data" in result.content[0].text
 
 
463
 
464
 
465
  class TestServerPrompts:
@@ -477,23 +526,26 @@ class TestServerPrompts:
477
  assert len(prompts) == 1
478
  assert prompts[0].name == "fn"
479
  # Don't compare functions directly since validate_call wraps them
480
- assert await prompts[0].render() == [
481
- UserMessage(content=TextContent(type="text", text="Hello, world!"))
482
- ]
483
 
484
- def test_prompt_decorator_with_name(self):
485
  """Test prompt decorator with custom name."""
486
  mcp = FastMCP()
487
 
488
- @mcp.prompt(name="custom")
489
  def fn() -> str:
490
  return "Hello, world!"
491
 
492
  prompts = mcp._prompt_manager.list_prompts()
493
  assert len(prompts) == 1
494
- assert prompts[0].name == "custom"
 
 
 
495
 
496
- def test_prompt_decorator_with_description(self):
497
  """Test prompt decorator with custom description."""
498
  mcp = FastMCP()
499
 
@@ -504,13 +556,16 @@ class TestServerPrompts:
504
  prompts = mcp._prompt_manager.list_prompts()
505
  assert len(prompts) == 1
506
  assert prompts[0].description == "A custom description"
 
 
 
507
 
508
  def test_prompt_decorator_error(self):
509
  """Test error when decorator is used incorrectly."""
510
  mcp = FastMCP()
511
  with pytest.raises(TypeError, match="decorator was used incorrectly"):
512
 
513
- @mcp.prompt
514
  def fn() -> str:
515
  return "Hello, world!"
516
 
@@ -524,13 +579,16 @@ class TestServerPrompts:
524
 
525
  async with client_session(mcp._mcp_server) as client:
526
  result = await client.list_prompts()
 
527
  assert len(result.prompts) == 1
528
- assert result.prompts[0].name == "fn"
529
- assert len(result.prompts[0].arguments) == 2
530
- assert result.prompts[0].arguments[0].name == "name"
531
- assert result.prompts[0].arguments[0].required is True
532
- assert result.prompts[0].arguments[1].name == "optional"
533
- assert result.prompts[0].arguments[1].required is False
 
 
534
 
535
  async def test_get_prompt(self):
536
  """Test getting a prompt through MCP protocol."""
@@ -543,9 +601,11 @@ class TestServerPrompts:
543
  async with client_session(mcp._mcp_server) as client:
544
  result = await client.get_prompt("fn", {"name": "World"})
545
  assert len(result.messages) == 1
546
- assert result.messages[0].role == "user"
547
- assert result.messages[0].content.type == "text"
548
- assert result.messages[0].content.text == "Hello, World!"
 
 
549
 
550
  async def test_get_prompt_with_resource(self):
551
  """Test getting a prompt that returns resource content."""
@@ -556,22 +616,25 @@ class TestServerPrompts:
556
  return UserMessage(
557
  content=EmbeddedResource(
558
  type="resource",
559
- resource={
560
- "uri": "file://test.txt",
561
- "text": "File contents",
562
- "mimeType": "text/plain",
563
- },
564
  )
565
  )
566
 
567
  async with client_session(mcp._mcp_server) as client:
568
  result = await client.get_prompt("fn")
569
  assert len(result.messages) == 1
570
- assert result.messages[0].role == "user"
571
- assert result.messages[0].content.type == "resource"
572
- assert str(result.messages[0].content.resource.uri) == "file://test.txt/"
573
- assert result.messages[0].content.resource.text == "File contents"
574
- assert result.messages[0].content.resource.mimeType == "text/plain"
 
 
 
575
 
576
  async def test_get_unknown_prompt(self):
577
  """Test error when getting unknown prompt."""
@@ -585,9 +648,9 @@ class TestServerPrompts:
585
  mcp = FastMCP()
586
 
587
  @mcp.prompt()
588
- def fn(name: str) -> str:
589
  return f"Hello, {name}!"
590
 
591
  async with client_session(mcp._mcp_server) as client:
592
  with pytest.raises(McpError, match="Missing required arguments"):
593
- await client.get_prompt("fn")
 
7
  from mcp.shared.memory import (
8
  create_connected_server_and_client_session as client_session,
9
  )
10
+ from mcp.types import (
11
+ ImageContent,
12
+ TextContent,
13
+ TextResourceContents,
14
+ BlobResourceContents,
15
+ )
16
+ from pydantic import AnyUrl
17
 
18
  from fastmcp import Context, FastMCP
19
  from fastmcp.prompts.base import EmbeddedResource, Message, UserMessage
 
106
  mcp.add_tool(tool_fn)
107
  async with client_session(mcp._mcp_server) as client:
108
  result = await client.call_tool("my_tool", {"arg1": "value"})
109
+ assert not hasattr(result, "error")
110
  assert len(result.content) > 0
111
 
112
  async def test_tool_exception_handling(self):
 
115
  async with client_session(mcp._mcp_server) as client:
116
  result = await client.call_tool("error_tool_fn", {})
117
  assert len(result.content) == 1
118
+ content = result.content[0]
119
+ assert isinstance(content, TextContent)
120
+ assert "Test error" in content.text
121
+ assert result.isError is True
122
+
123
+ async def test_tool_error_handling(self):
124
+ mcp = FastMCP()
125
+ mcp.add_tool(error_tool_fn)
126
+ async with client_session(mcp._mcp_server) as client:
127
+ result = await client.call_tool("error_tool_fn", {})
128
+ assert len(result.content) == 1
129
+ content = result.content[0]
130
+ assert isinstance(content, TextContent)
131
+ assert "Test error" in content.text
132
  assert result.isError is True
133
 
134
+ async def test_tool_error_details(self):
135
  """Test that exception details are properly formatted in the response"""
136
  mcp = FastMCP()
137
  mcp.add_tool(error_tool_fn)
138
  async with client_session(mcp._mcp_server) as client:
139
  result = await client.call_tool("error_tool_fn", {})
140
+ content = result.content[0]
141
+ assert isinstance(content, TextContent)
142
+ assert isinstance(content.text, str)
143
+ assert "Test error" in content.text
144
  assert result.isError is True
145
 
146
+ async def test_tool_return_value_conversion(self):
147
  mcp = FastMCP()
148
  mcp.add_tool(tool_fn)
149
  async with client_session(mcp._mcp_server) as client:
150
  result = await client.call_tool("tool_fn", {"x": 1, "y": 2})
151
  assert len(result.content) == 1
152
+ content = result.content[0]
153
+ assert isinstance(content, TextContent)
154
+ assert content.text == "3"
155
 
156
  async def test_tool_image_helper(self, tmp_path: Path):
157
  # Create a test image
 
163
  async with client_session(mcp._mcp_server) as client:
164
  result = await client.call_tool("image_tool_fn", {"path": str(image_path)})
165
  assert len(result.content) == 1
166
+ content = result.content[0]
167
+ assert isinstance(content, ImageContent)
168
+ assert content.type == "image"
169
+ assert content.mimeType == "image/png"
170
  # Verify base64 encoding
171
+ decoded = base64.b64decode(content.data)
172
  assert decoded == b"fake png data"
173
 
174
  async def test_tool_mixed_content(self):
 
177
  async with client_session(mcp._mcp_server) as client:
178
  result = await client.call_tool("mixed_content_tool_fn", {})
179
  assert len(result.content) == 2
180
+ content1 = result.content[0]
181
+ content2 = result.content[1]
182
+ assert isinstance(content1, TextContent)
183
+ assert content1.text == "Hello"
184
+ assert isinstance(content2, ImageContent)
185
+ assert content2.mimeType == "image/png"
186
+ assert content2.data == "abc"
187
 
188
  async def test_tool_mixed_list_with_image(self, tmp_path: Path):
189
  """Test that lists containing Image objects and other types are handled correctly"""
 
205
  result = await client.call_tool("mixed_list_fn", {})
206
  assert len(result.content) == 4
207
  # Check text conversion
208
+ content1 = result.content[0]
209
+ assert isinstance(content1, TextContent)
210
+ assert content1.text == "text message"
211
  # Check image conversion
212
+ content2 = result.content[1]
213
+ assert isinstance(content2, ImageContent)
214
+ assert content2.mimeType == "image/png"
215
+ assert base64.b64decode(content2.data) == b"test image data"
216
  # Check dict conversion
217
+ content3 = result.content[2]
218
+ assert isinstance(content3, TextContent)
219
+ assert '"key": "value"' in content3.text
220
  # Check direct TextContent
221
+ content4 = result.content[3]
222
+ assert isinstance(content4, TextContent)
223
+ assert content4.text == "direct content"
224
 
225
 
226
  class TestServerResources:
 
230
  def get_text():
231
  return "Hello, world!"
232
 
233
+ resource = FunctionResource(
234
+ uri=AnyUrl("resource://test"), name="test", fn=get_text
235
+ )
236
  mcp.add_resource(resource)
237
 
238
  async with client_session(mcp._mcp_server) as client:
239
+ result = await client.read_resource(AnyUrl("resource://test"))
240
+ assert isinstance(result.contents[0], TextResourceContents)
241
  assert result.contents[0].text == "Hello, world!"
242
 
243
  async def test_binary_resource(self):
 
247
  return b"Binary data"
248
 
249
  resource = FunctionResource(
250
+ uri=AnyUrl("resource://binary"),
251
  name="binary",
252
  fn=get_binary,
 
253
  mime_type="application/octet-stream",
254
  )
255
  mcp.add_resource(resource)
256
 
257
  async with client_session(mcp._mcp_server) as client:
258
+ result = await client.read_resource(AnyUrl("resource://binary"))
259
+ assert isinstance(result.contents[0], BlobResourceContents)
260
  assert result.contents[0].blob == base64.b64encode(b"Binary data").decode()
261
 
262
  async def test_file_resource_text(self, tmp_path: Path):
 
266
  text_file = tmp_path / "test.txt"
267
  text_file.write_text("Hello from file!")
268
 
269
+ resource = FileResource(
270
+ uri=AnyUrl("file://test.txt"), name="test.txt", path=text_file
271
+ )
272
  mcp.add_resource(resource)
273
 
274
  async with client_session(mcp._mcp_server) as client:
275
+ result = await client.read_resource(AnyUrl("file://test.txt"))
276
+ assert isinstance(result.contents[0], TextResourceContents)
277
  assert result.contents[0].text == "Hello from file!"
278
 
279
  async def test_file_resource_binary(self, tmp_path: Path):
 
284
  binary_file.write_bytes(b"Binary file data")
285
 
286
  resource = FileResource(
287
+ uri=AnyUrl("file://test.bin"),
288
  name="test.bin",
289
  path=binary_file,
 
290
  mime_type="application/octet-stream",
291
  )
292
  mcp.add_resource(resource)
293
 
294
  async with client_session(mcp._mcp_server) as client:
295
+ result = await client.read_resource(AnyUrl("file://test.bin"))
296
+ assert isinstance(result.contents[0], BlobResourceContents)
297
  assert (
298
  result.contents[0].blob
299
  == base64.b64encode(b"Binary file data").decode()
 
309
  with pytest.raises(ValueError, match="Mismatch between URI parameters"):
310
 
311
  @mcp.resource("resource://data")
312
+ def get_data_fn(param: str) -> str:
313
  return f"Data: {param}"
314
 
315
  async def test_resource_with_uri_params(self):
 
339
  return f"Data for {name}"
340
 
341
  async with client_session(mcp._mcp_server) as client:
342
+ result = await client.read_resource(AnyUrl("resource://test/data"))
343
+ assert isinstance(result.contents[0], TextResourceContents)
344
  assert result.contents[0].text == "Data for test"
345
 
346
  async def test_resource_mismatched_params(self):
 
362
  return f"Data for {org}/{repo}"
363
 
364
  async with client_session(mcp._mcp_server) as client:
365
+ result = await client.read_resource(
366
+ AnyUrl("resource://cursor/fastmcp/data")
367
+ )
368
+ assert isinstance(result.contents[0], TextResourceContents)
369
  assert result.contents[0].text == "Data for cursor/fastmcp"
370
 
371
  async def test_resource_multiple_mismatched_params(self):
 
375
  with pytest.raises(ValueError, match="Mismatch between URI parameters"):
376
 
377
  @mcp.resource("resource://{org}/{repo}/data")
378
+ def get_data_mismatched(org: str, repo_2: str) -> str:
379
  return f"Data for {org}"
380
 
381
  """Test that a resource with no parameters works as a regular resource"""
382
  mcp = FastMCP()
383
 
384
  @mcp.resource("resource://static")
385
+ def get_static_data() -> str:
386
  return "Static data"
387
 
388
  async with client_session(mcp._mcp_server) as client:
389
+ result = await client.read_resource(AnyUrl("resource://static"))
390
+ assert isinstance(result.contents[0], TextResourceContents)
391
  assert result.contents[0].text == "Static data"
392
 
393
  async def test_template_to_resource_conversion(self):
 
434
  async with client_session(mcp._mcp_server) as client:
435
  result = await client.call_tool("tool_with_context", {"x": 42})
436
  assert len(result.content) == 1
437
+ content = result.content[0]
438
+ assert isinstance(content, TextContent)
439
+ assert "Request" in content.text
440
+ assert "42" in content.text
441
 
442
  async def test_async_context(self):
443
  """Test that context works in async functions."""
 
451
  async with client_session(mcp._mcp_server) as client:
452
  result = await client.call_tool("async_tool", {"x": 42})
453
  assert len(result.content) == 1
454
+ content = result.content[0]
455
+ assert isinstance(content, TextContent)
456
+ assert "Async request" in content.text
457
+ assert "42" in content.text
458
 
459
  async def test_context_logging(self):
460
  """Test that context logging methods work."""
 
471
  async with client_session(mcp._mcp_server) as client:
472
  result = await client.call_tool("logging_tool", {"msg": "test"})
473
  assert len(result.content) == 1
474
+ content = result.content[0]
475
+ assert isinstance(content, TextContent)
476
+ assert "Logged messages for test" in content.text
477
 
478
  async def test_optional_context(self):
479
  """Test that context is optional."""
 
486
  async with client_session(mcp._mcp_server) as client:
487
  result = await client.call_tool("no_context", {"x": 21})
488
  assert len(result.content) == 1
489
+ content = result.content[0]
490
+ assert isinstance(content, TextContent)
491
+ assert content.text == "42"
492
 
493
  async def test_context_resource_access(self):
494
  """Test that context can access resources."""
 
506
  async with client_session(mcp._mcp_server) as client:
507
  result = await client.call_tool("tool_with_resource", {})
508
  assert len(result.content) == 1
509
+ content = result.content[0]
510
+ assert isinstance(content, TextContent)
511
+ assert "Read resource: resource data" in content.text
512
 
513
 
514
  class TestServerPrompts:
 
526
  assert len(prompts) == 1
527
  assert prompts[0].name == "fn"
528
  # Don't compare functions directly since validate_call wraps them
529
+ content = await prompts[0].render()
530
+ assert isinstance(content[0].content, TextContent)
531
+ assert content[0].content.text == "Hello, world!"
532
 
533
+ async def test_prompt_decorator_with_name(self):
534
  """Test prompt decorator with custom name."""
535
  mcp = FastMCP()
536
 
537
+ @mcp.prompt(name="custom_name")
538
  def fn() -> str:
539
  return "Hello, world!"
540
 
541
  prompts = mcp._prompt_manager.list_prompts()
542
  assert len(prompts) == 1
543
+ assert prompts[0].name == "custom_name"
544
+ content = await prompts[0].render()
545
+ assert isinstance(content[0].content, TextContent)
546
+ assert content[0].content.text == "Hello, world!"
547
 
548
+ async def test_prompt_decorator_with_description(self):
549
  """Test prompt decorator with custom description."""
550
  mcp = FastMCP()
551
 
 
556
  prompts = mcp._prompt_manager.list_prompts()
557
  assert len(prompts) == 1
558
  assert prompts[0].description == "A custom description"
559
+ content = await prompts[0].render()
560
+ assert isinstance(content[0].content, TextContent)
561
+ assert content[0].content.text == "Hello, world!"
562
 
563
  def test_prompt_decorator_error(self):
564
  """Test error when decorator is used incorrectly."""
565
  mcp = FastMCP()
566
  with pytest.raises(TypeError, match="decorator was used incorrectly"):
567
 
568
+ @mcp.prompt # type: ignore
569
  def fn() -> str:
570
  return "Hello, world!"
571
 
 
579
 
580
  async with client_session(mcp._mcp_server) as client:
581
  result = await client.list_prompts()
582
+ assert result.prompts is not None
583
  assert len(result.prompts) == 1
584
+ prompt = result.prompts[0]
585
+ assert prompt.name == "fn"
586
+ assert prompt.arguments is not None
587
+ assert len(prompt.arguments) == 2
588
+ assert prompt.arguments[0].name == "name"
589
+ assert prompt.arguments[0].required is True
590
+ assert prompt.arguments[1].name == "optional"
591
+ assert prompt.arguments[1].required is False
592
 
593
  async def test_get_prompt(self):
594
  """Test getting a prompt through MCP protocol."""
 
601
  async with client_session(mcp._mcp_server) as client:
602
  result = await client.get_prompt("fn", {"name": "World"})
603
  assert len(result.messages) == 1
604
+ message = result.messages[0]
605
+ assert message.role == "user"
606
+ content = message.content
607
+ assert isinstance(content, TextContent)
608
+ assert content.text == "Hello, World!"
609
 
610
  async def test_get_prompt_with_resource(self):
611
  """Test getting a prompt that returns resource content."""
 
616
  return UserMessage(
617
  content=EmbeddedResource(
618
  type="resource",
619
+ resource=TextResourceContents(
620
+ uri=AnyUrl("file://file.txt"),
621
+ text="File contents",
622
+ mimeType="text/plain",
623
+ ),
624
  )
625
  )
626
 
627
  async with client_session(mcp._mcp_server) as client:
628
  result = await client.get_prompt("fn")
629
  assert len(result.messages) == 1
630
+ message = result.messages[0]
631
+ assert message.role == "user"
632
+ content = message.content
633
+ assert isinstance(content, EmbeddedResource)
634
+ resource = content.resource
635
+ assert isinstance(resource, TextResourceContents)
636
+ assert resource.text == "File contents"
637
+ assert resource.mimeType == "text/plain"
638
 
639
  async def test_get_unknown_prompt(self):
640
  """Test error when getting unknown prompt."""
 
648
  mcp = FastMCP()
649
 
650
  @mcp.prompt()
651
+ def prompt_fn(name: str) -> str:
652
  return f"Hello, {name}!"
653
 
654
  async with client_session(mcp._mcp_server) as client:
655
  with pytest.raises(McpError, match="Missing required arguments"):
656
+ await client.get_prompt("prompt_fn")
uv.lock CHANGED
@@ -228,7 +228,7 @@ wheels = [
228
 
229
  [[package]]
230
  name = "fastmcp"
231
- version = "0.3.6.dev0+gf03184b.d20241203"
232
  source = { editable = "." }
233
  dependencies = [
234
  { name = "httpx" },
@@ -245,6 +245,7 @@ dev = [
245
  { name = "ipython" },
246
  { name = "pdbpp" },
247
  { name = "pre-commit" },
 
248
  { name = "pytest" },
249
  { name = "pytest-asyncio" },
250
  { name = "pytest-flakefinder" },
@@ -253,6 +254,7 @@ dev = [
253
  ]
254
  tests = [
255
  { name = "pre-commit" },
 
256
  { name = "pytest" },
257
  { name = "pytest-asyncio" },
258
  { name = "pytest-flakefinder" },
@@ -271,6 +273,8 @@ requires-dist = [
271
  { name = "pre-commit", marker = "extra == 'tests'" },
272
  { name = "pydantic", specifier = ">=2.5.3,<3.0.0" },
273
  { name = "pydantic-settings", specifier = ">=2.6.1" },
 
 
274
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.3" },
275
  { name = "pytest", marker = "extra == 'tests'", specifier = ">=8.3.3" },
276
  { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.5" },
@@ -730,6 +734,19 @@ version = "0.9.0"
730
  source = { registry = "https://pypi.org/simple" }
731
  sdist = { url = "https://files.pythonhosted.org/packages/05/1b/ea40363be0056080454cdbabe880773c3c5bd66d7b13f0c8b8b8c8da1e0c/pyrepl-0.9.0.tar.gz", hash = "sha256:292570f34b5502e871bbb966d639474f2b57fbfcd3373c2d6a2f3d56e681a775", size = 48744 }
732
 
 
 
 
 
 
 
 
 
 
 
 
 
 
733
  [[package]]
734
  name = "pytest"
735
  version = "8.3.3"
 
228
 
229
  [[package]]
230
  name = "fastmcp"
231
+ version = "0.3.6.dev5+g6a13ab9.d20241203"
232
  source = { editable = "." }
233
  dependencies = [
234
  { name = "httpx" },
 
245
  { name = "ipython" },
246
  { name = "pdbpp" },
247
  { name = "pre-commit" },
248
+ { name = "pyright" },
249
  { name = "pytest" },
250
  { name = "pytest-asyncio" },
251
  { name = "pytest-flakefinder" },
 
254
  ]
255
  tests = [
256
  { name = "pre-commit" },
257
+ { name = "pyright" },
258
  { name = "pytest" },
259
  { name = "pytest-asyncio" },
260
  { name = "pytest-flakefinder" },
 
273
  { name = "pre-commit", marker = "extra == 'tests'" },
274
  { name = "pydantic", specifier = ">=2.5.3,<3.0.0" },
275
  { name = "pydantic-settings", specifier = ">=2.6.1" },
276
+ { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.389" },
277
+ { name = "pyright", marker = "extra == 'tests'", specifier = ">=1.1.389" },
278
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.3" },
279
  { name = "pytest", marker = "extra == 'tests'", specifier = ">=8.3.3" },
280
  { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.5" },
 
734
  source = { registry = "https://pypi.org/simple" }
735
  sdist = { url = "https://files.pythonhosted.org/packages/05/1b/ea40363be0056080454cdbabe880773c3c5bd66d7b13f0c8b8b8c8da1e0c/pyrepl-0.9.0.tar.gz", hash = "sha256:292570f34b5502e871bbb966d639474f2b57fbfcd3373c2d6a2f3d56e681a775", size = 48744 }
736
 
737
+ [[package]]
738
+ name = "pyright"
739
+ version = "1.1.389"
740
+ source = { registry = "https://pypi.org/simple" }
741
+ dependencies = [
742
+ { name = "nodeenv" },
743
+ { name = "typing-extensions" },
744
+ ]
745
+ sdist = { url = "https://files.pythonhosted.org/packages/72/4e/9a5ab8745e7606b88c2c7ca223449ac9d82a71fd5e31df47b453f2cb39a1/pyright-1.1.389.tar.gz", hash = "sha256:716bf8cc174ab8b4dcf6828c3298cac05c5ed775dda9910106a5dcfe4c7fe220", size = 21940 }
746
+ wheels = [
747
+ { url = "https://files.pythonhosted.org/packages/1b/26/c288cabf8cfc5a27e1aa9e5029b7682c0f920b8074f45d22bf844314d66a/pyright-1.1.389-py3-none-any.whl", hash = "sha256:41e9620bba9254406dc1f621a88ceab5a88af4c826feb4f614d95691ed243a60", size = 18581 },
748
+ ]
749
+
750
  [[package]]
751
  name = "pytest"
752
  version = "8.3.3"