Skip to content

Commit 8cce799

Browse files
authored
Merge branch 'main' into use-stderr-for-logs
2 parents 4e44366 + 5ddec50 commit 8cce799

21 files changed

+378
-291
lines changed

.github/workflows/lint.yml .github/workflows/run-static.yml

+6
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,9 @@ jobs:
2828
python-version: "3.12"
2929
- name: Run pre-commit
3030
uses: pre-commit/[email protected]
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

+13
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ build-backend = "hatchling.build"
2525
[project.optional-dependencies]
2626
tests = [
2727
"pre-commit",
28+
"pyright>=1.1.389",
2829
"pytest>=8.3.3",
2930
"pytest-asyncio>=0.23.5",
3031
"pytest-flakefinder",
@@ -39,3 +40,15 @@ asyncio_default_fixture_loop_scope = "session"
3940

4041
[tool.hatch.version]
4142
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

+6-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import importlib.metadata
44
import importlib.util
5+
import os
56
import subprocess
67
import sys
78
from pathlib import Path
@@ -242,6 +243,7 @@ def dev(
242243
[npx_cmd, "@modelcontextprotocol/inspector"] + uv_cmd,
243244
check=True,
244245
shell=shell,
246+
env=dict(os.environ.items()), # Convert to list of tuples for env update
245247
)
246248
sys.exit(process.returncode)
247249
except subprocess.CalledProcessError as e:
@@ -423,7 +425,10 @@ def install(
423425
# Load from .env file if specified
424426
if env_file:
425427
try:
426-
env_dict.update(dotenv.dotenv_values(env_file))
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+
)
427432
except Exception as e:
428433
logger.error(f"Failed to load .env file: {e}")
429434
sys.exit(1)

src/fastmcp/prompts/base.py

+29-13
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,52 @@
11
"""Base classes for FastMCP prompts."""
22

33
import json
4-
from typing import Any, Callable, Dict, Literal, Optional, Sequence, Union
4+
from typing import Any, Callable, Dict, Literal, Optional, Sequence, Awaitable
55
import inspect
66

7-
from pydantic import BaseModel, Field, TypeAdapter, field_validator, validate_call
7+
from pydantic import BaseModel, Field, TypeAdapter, validate_call
88
from mcp.types import TextContent, ImageContent, EmbeddedResource
99
import pydantic_core
1010

11+
CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource
12+
1113

1214
class Message(BaseModel):
1315
"""Base class for all prompt messages."""
1416

1517
role: Literal["user", "assistant"]
16-
content: Union[TextContent, ImageContent, EmbeddedResource]
18+
content: CONTENT_TYPES
1719

18-
def __init__(self, content, **kwargs):
20+
def __init__(self, content: str | CONTENT_TYPES, **kwargs):
21+
if isinstance(content, str):
22+
content = TextContent(type="text", text=content)
1923
super().__init__(content=content, **kwargs)
2024

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-
2725

2826
class UserMessage(Message):
2927
"""A message from the user."""
3028

3129
role: Literal["user"] = "user"
3230

31+
def __init__(self, content: str | CONTENT_TYPES, **kwargs):
32+
super().__init__(content=content, **kwargs)
33+
3334

3435
class AssistantMessage(Message):
3536
"""A message from the assistant."""
3637

3738
role: Literal["assistant"] = "assistant"
3839

40+
def __init__(self, content: str | CONTENT_TYPES, **kwargs):
41+
super().__init__(content=content, **kwargs)
42+
43+
44+
message_validator = TypeAdapter(UserMessage | AssistantMessage)
3945

40-
message_validator = TypeAdapter(Union[UserMessage, AssistantMessage])
46+
SyncPromptResult = (
47+
str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]]
48+
)
49+
PromptResult = SyncPromptResult | Awaitable[SyncPromptResult]
4150

4251

4352
class PromptArgument(BaseModel):
@@ -67,11 +76,18 @@ class Prompt(BaseModel):
6776
@classmethod
6877
def from_function(
6978
cls,
70-
fn: Callable[..., Sequence[Message]],
79+
fn: Callable[..., PromptResult],
7180
name: Optional[str] = None,
7281
description: Optional[str] = None,
7382
) -> "Prompt":
74-
"""Create a Prompt from a function."""
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+
"""
7591
func_name = name or fn.__name__
7692

7793
if func_name == "<lambda>":

src/fastmcp/resources/base.py

+5-13
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
"""Base classes and interfaces for FastMCP resources."""
22

33
import abc
4-
from typing import Union
4+
from typing import Union, Annotated
55

66
from pydantic import (
77
AnyUrl,
88
BaseModel,
99
ConfigDict,
1010
Field,
11-
FileUrl,
11+
UrlConstraints,
1212
ValidationInfo,
1313
field_validator,
1414
)
@@ -19,8 +19,9 @@ class Resource(BaseModel, abc.ABC):
1919

2020
model_config = ConfigDict(validate_default=True)
2121

22-
# uri: Annotated[AnyUrl, BeforeValidator(maybe_cast_str_to_any_url)] = Field(
23-
uri: AnyUrl = Field(default=..., description="URI of the resource")
22+
uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field(
23+
default=..., description="URI of the resource"
24+
)
2425
name: str | None = Field(description="Name of the resource", default=None)
2526
description: str | None = Field(
2627
description="Description of the resource", default=None
@@ -31,15 +32,6 @@ class Resource(BaseModel, abc.ABC):
3132
pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
3233
)
3334

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-
4335
@field_validator("name", mode="before")
4436
@classmethod
4537
def set_default_name(cls, name: str | None, info: ValidationInfo) -> str:

src/fastmcp/resources/templates.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ async def create_resource(self, uri: str, params: Dict[str, Any]) -> Resource:
7070
result = await result
7171

7272
return FunctionResource(
73-
uri=uri,
73+
uri=uri, # type: ignore
7474
name=self.name,
7575
description=self.description,
7676
mime_type=self.mime_type,

src/fastmcp/resources/types.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import httpx
99
import pydantic.json
1010
import pydantic_core
11-
from pydantic import Field
11+
from pydantic import Field, ValidationInfo
1212

1313
from fastmcp.resources.base import Resource
1414

@@ -91,6 +91,15 @@ def validate_absolute_path(cls, path: Path) -> Path:
9191
raise ValueError("Path must be absolute")
9292
return path
9393

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+
94103
async def read(self) -> Union[str, bytes]:
95104
"""Read the file content."""
96105
try:

src/fastmcp/server.py

+7-6
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
)
2424
from mcp.types import (
2525
Prompt as MCPPrompt,
26+
PromptArgument as MCPPromptArgument,
2627
)
2728
from mcp.types import (
2829
Resource as MCPResource,
@@ -159,7 +160,7 @@ def get_context(self) -> "Context":
159160

160161
async def call_tool(
161162
self, name: str, arguments: dict
162-
) -> Sequence[TextContent | ImageContent]:
163+
) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
163164
"""Call a tool by name with arguments."""
164165
context = self.get_context()
165166
result = await self._tool_manager.call_tool(name, arguments, context=context)
@@ -462,11 +463,11 @@ async def list_prompts(self) -> list[MCPPrompt]:
462463
name=prompt.name,
463464
description=prompt.description,
464465
arguments=[
465-
{
466-
"name": arg.name,
467-
"description": arg.description,
468-
"required": arg.required,
469-
}
466+
MCPPromptArgument(
467+
name=arg.name,
468+
description=arg.description,
469+
required=arg.required,
470+
)
470471
for arg in (prompt.arguments or [])
471472
],
472473
)

src/fastmcp/utilities/func_metadata.py

+8-3
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ class FuncMetadata(BaseModel):
4747

4848
async def call_fn_with_arg_validation(
4949
self,
50-
fn: Callable | Awaitable,
50+
fn: Callable[..., Any] | Awaitable[Any],
5151
fn_is_async: bool,
5252
arguments_to_validate: dict[str, Any],
5353
arguments_to_pass_directly: dict[str, Any] | None,
@@ -64,8 +64,12 @@ async def call_fn_with_arg_validation(
6464
arguments_parsed_dict |= arguments_to_pass_directly or {}
6565

6666
if fn_is_async:
67+
if isinstance(fn, Awaitable):
68+
return await fn
6769
return await fn(**arguments_parsed_dict)
68-
return 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")
6973

7074
def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
7175
"""Pre-parse data from JSON.
@@ -123,6 +127,7 @@ def func_metadata(func: Callable, skip_names: Sequence[str] = ()) -> FuncMetadat
123127
sig = _get_typed_signature(func)
124128
params = sig.parameters
125129
dynamic_pydantic_model_params: dict[str, Any] = {}
130+
globalns = getattr(func, "__globals__", {})
126131
for param in params.values():
127132
if param.name.startswith("_"):
128133
raise InvalidSignature(
@@ -153,7 +158,7 @@ def func_metadata(func: Callable, skip_names: Sequence[str] = ()) -> FuncMetadat
153158
]
154159

155160
field_info = FieldInfo.from_annotated_attribute(
156-
annotation,
161+
_get_typed_annotation(annotation, globalns),
157162
param.default
158163
if param.default is not inspect.Parameter.empty
159164
else PydanticUndefined,

src/fastmcp/utilities/types.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ def to_image_content(self) -> ImageContent:
4747
if self.path:
4848
with open(self.path, "rb") as f:
4949
data = base64.b64encode(f.read()).decode()
50-
else:
50+
elif self.data is not None:
5151
data = base64.b64encode(self.data).decode()
52+
else:
53+
raise ValueError("No image data available")
5254

5355
return ImageContent(type="image", data=data, mimeType=self._mime_type)

tests/prompts/test_base.py

+7-6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from pydantic import FileUrl
12
import pytest
23
from fastmcp.prompts.base import (
34
Prompt,
@@ -102,7 +103,7 @@ async def fn() -> UserMessage:
102103
content=EmbeddedResource(
103104
type="resource",
104105
resource=TextResourceContents(
105-
uri="file://file.txt",
106+
uri=FileUrl("file://file.txt"),
106107
text="File contents",
107108
mimeType="text/plain",
108109
),
@@ -115,7 +116,7 @@ async def fn() -> UserMessage:
115116
content=EmbeddedResource(
116117
type="resource",
117118
resource=TextResourceContents(
118-
uri="file://file.txt",
119+
uri=FileUrl("file://file.txt"),
119120
text="File contents",
120121
mimeType="text/plain",
121122
),
@@ -133,7 +134,7 @@ async def fn() -> list[Message]:
133134
content=EmbeddedResource(
134135
type="resource",
135136
resource=TextResourceContents(
136-
uri="file://file.txt",
137+
uri=FileUrl("file://file.txt"),
137138
text="File contents",
138139
mimeType="text/plain",
139140
),
@@ -151,7 +152,7 @@ async def fn() -> list[Message]:
151152
content=EmbeddedResource(
152153
type="resource",
153154
resource=TextResourceContents(
154-
uri="file://file.txt",
155+
uri=FileUrl("file://file.txt"),
155156
text="File contents",
156157
mimeType="text/plain",
157158
),
@@ -171,7 +172,7 @@ async def fn() -> dict:
171172
"content": {
172173
"type": "resource",
173174
"resource": {
174-
"uri": "file://file.txt",
175+
"uri": FileUrl("file://file.txt"),
175176
"text": "File contents",
176177
"mimeType": "text/plain",
177178
},
@@ -184,7 +185,7 @@ async def fn() -> dict:
184185
content=EmbeddedResource(
185186
type="resource",
186187
resource=TextResourceContents(
187-
uri="file://file.txt",
188+
uri=FileUrl("file://file.txt"),
188189
text="File contents",
189190
mimeType="text/plain",
190191
),

0 commit comments

Comments
 (0)